diff --git a/core/src/CMakeLists.txt b/core/src/CMakeLists.txt index 8f0b6e47..487fe32b 100644 --- a/core/src/CMakeLists.txt +++ b/core/src/CMakeLists.txt @@ -1,13 +1,21 @@ cmake_minimum_required(VERSION 3.10) -project(plc_application C) +project(plc_application CXX C) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) # Find Python development libraries find_package(PkgConfig REQUIRED) pkg_check_modules(PYTHON REQUIRED python3-embed) -# Include directories +# Include directories. +# +# Runtime-side strucpp assets live in: +# core/src/lib/strucpp_abi.hpp — ABI mirror used to walk the .so +# We do NOT vendor strucpp's full runtime headers into the runtime — +# those ship with each user-program upload and are only consumed by +# scripts/compile.sh when building the .so. include_directories(${CMAKE_SOURCE_DIR}/core/src/plc_app ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils ${CMAKE_SOURCE_DIR}/core/generated/plc_lib @@ -15,15 +23,13 @@ include_directories(${CMAKE_SOURCE_DIR}/core/src/plc_app ${PYTHON_INCLUDE_DIRS}) # Compiler options -add_compile_options(-Wall -Werror -Wextra -fstack-protector-strong +add_compile_options(-Wall -Werror -Wextra -fstack-protector-strong -D_FORTIFY_SOURCE=2 -O2 -Wformat -Werror=format-security -fPIC -fPIE) # On Cygwin/MSYS2, disable treating warnings as errors due to header conflicts # The _POSIX_C_SOURCE redefinition warning is caused by Python headers vs system headers -# Detection: CMAKE_SYSTEM_NAME is "CYGWIN" or "MSYS" on these platforms if(CMAKE_SYSTEM_NAME MATCHES "CYGWIN|MSYS") - # Remove -Werror but keep all other warnings and specific -Werror=format-security add_compile_options(-Wno-error) endif() @@ -33,14 +39,14 @@ add_executable(plc_main ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/log.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/utils.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/utils/watchdog.c - ${CMAKE_SOURCE_DIR}/core/src/plc_app/image_tables.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/image_tables.cpp ${CMAKE_SOURCE_DIR}/core/src/plc_app/journal_buffer.c - ${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_state_manager.c + ${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_io_cycle.cpp + ${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_state_manager.cpp ${CMAKE_SOURCE_DIR}/core/src/plc_app/plcapp_manager.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/scan_cycle_manager.c ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_driver.c ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_config.c - ${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_utils.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/unix_socket.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_handler.c ${CMAKE_SOURCE_DIR}/core/src/plc_app/client_tcp_udp.c diff --git a/core/src/drivers/plugin_driver.c b/core/src/drivers/plugin_driver.c index d424a434..a7fc2e84 100644 --- a/core/src/drivers/plugin_driver.c +++ b/core/src/drivers/plugin_driver.c @@ -15,11 +15,12 @@ #include "../plc_app/image_tables.h" #include "../plc_app/journal_buffer.h" +#include "../plc_app/plc_state_manager.h" +#include "../plc_app/unix_socket.h" #include "../plc_app/utils/log.h" #include "../plc_app/utils/utils.h" #include "plugin_config.h" #include "plugin_driver.h" -#include "plugin_utils.h" #include #include #include @@ -108,6 +109,73 @@ static int plugin_journal_write_lint(int type, int index, unsigned long long val return journal_write_lint((journal_buffer_type_t)type, (uint16_t)index, (uint64_t)value); } +// STruC++ debugger thunks. Forward to ext_strucpp_debug_* function +// pointers resolved from the program .so by image_tables symbols_init. +// All five tolerate ext_*==NULL (program not yet loaded) and return a +// safe sentinel: counts → 0, debug_set/debug_write → STATUS_OUT_OF_BOUNDS +// (0x81), debug_read → 0 bytes written. + +static uint8_t plugin_debug_array_count(void) +{ + return ext_strucpp_debug_array_count ? ext_strucpp_debug_array_count() : 0; +} + +static uint16_t plugin_debug_elem_count(uint8_t arr) +{ + return ext_strucpp_debug_elem_count ? ext_strucpp_debug_elem_count(arr) : 0; +} + +static uint16_t plugin_debug_size(uint8_t arr, uint16_t elem) +{ + return ext_strucpp_debug_size ? ext_strucpp_debug_size(arr, elem) : 0; +} + +static uint16_t plugin_debug_read(uint8_t arr, uint16_t elem, uint8_t *dest) +{ + return ext_strucpp_debug_read ? ext_strucpp_debug_read(arr, elem, dest) : 0; +} + +static uint8_t plugin_debug_set(uint8_t arr, uint16_t elem, bool forcing, + const uint8_t *bytes, uint16_t len) +{ + return ext_strucpp_debug_set + ? ext_strucpp_debug_set(arr, elem, forcing, bytes, len) + : 0x81; // STATUS_OUT_OF_BOUNDS +} + +static uint8_t plugin_debug_write(uint8_t arr, uint16_t elem, + const uint8_t *bytes, uint16_t len) +{ + return ext_strucpp_debug_write + ? ext_strucpp_debug_write(arr, elem, bytes, len) + : 0x81; // STATUS_OUT_OF_BOUNDS +} + +// Plugin-invoked async PLC stop. Logs the reason at error level and kicks +// off a detached state-transition worker via the same path the unix-socket +// STOP command uses — the transition flag blocks overlapping commands, and +// all plugins get their stop_loop / cleanup hooks called in the normal +// order. Non-blocking by design: the caller's I/O thread returns +// immediately, then continues running for the brief window until the +// plugin's own stop_loop is invoked. Plugins that enter fault-stopped state +// are expected to short-circuit their I/O during that window. +// +// No pre-check on plc_get_state() here: plc_begin_transition does the +// check atomically (under the same gate that prevents concurrent +// transitions), so doing it again outside would just re-introduce the +// check-then-act race the gate is there to close. +static void plugin_request_plc_stop(const char *reason) +{ + log_error("[PLUGIN] stop requested: %s", reason ? reason : "(no reason given)"); + if (!plc_begin_transition(PLC_STATE_STOPPED)) + { + // Either the PLC is already stopping/stopped or another stop + // is already in flight — either way, nothing to do. + log_warn("[PLUGIN] stop request collapsed (already transitioning or not running)"); + } +} + + // Python capsule destructor for runtime args // Breakpoint here to debug capsule issues static void plugin_runtime_args_capsule_destructor(PyObject *capsule) @@ -141,6 +209,57 @@ static PyObject *create_python_runtime_args_capsule(plugin_runtime_args_t *args) return capsule; } +/* Tear down a single plugin instance: cleanup hook (if init() ran), + * close native handles, release Python refs. Leaves the slot zeroed. + * + * IMPORTANT: dispatched on the slot's CURRENT stored type, not on the + * incoming config's type — that's the bug fix for the slot-positional + * reload issue. Closing by slot index assumed configs[w].type matched + * driver->plugins[w].config.type, which falls apart whenever the user + * reorders / replaces / changes the type of an entry in plugins.conf. + * + * Caller must ensure the plugin is not running (this function is called + * from update_config which only runs after STOP). The dlclose is unsafe + * on a live plugin — once the .so is unmapped, any in-flight call into + * its function pointers segfaults. */ +static void teardown_plugin_instance(plugin_instance_t *plugin) +{ + if (!plugin) return; + + if (plugin->running) + { + log_error("[PLUGIN] internal error: tearing down running plugin '%s' — refusing", + plugin->config.name); + return; + } + + if (plugin->config.type == PLUGIN_TYPE_PYTHON && plugin->python_plugin) + { + // python_plugin_cleanup invokes the optional cleanup() if the + // plugin had been initialised, then DECREFs all module refs and + // frees the python_plugin bundle (sets the field to NULL). + // Calling it on an uninitialised plugin still releases module + // refs cleanly, so always call it as long as python_plugin is set. + python_plugin_cleanup(plugin); + } + else if (plugin->config.type == PLUGIN_TYPE_NATIVE && plugin->native_plugin) + { + if (plugin->initialized && plugin->native_plugin->cleanup) + { + plugin->native_plugin->cleanup(); + } + if (plugin->native_plugin->handle) + { + dlclose(plugin->native_plugin->handle); + } + free(plugin->native_plugin); + plugin->native_plugin = NULL; + } + + plugin->initialized = 0; + memset(&plugin->config, 0, sizeof(plugin->config)); +} + int plugin_driver_update_config(plugin_driver_t *driver, const char *config_file) { if (!driver || !config_file) @@ -193,53 +312,204 @@ int plugin_driver_update_config(plugin_driver_t *driver, const char *config_file return -1; } + /* Tear down ALL old plugins, dispatched by their CURRENT stored type + * (not the new config's type). This fixes the slot-positional reload + * bug: previously a slot whose type changed Native→Python would skip + * the dlclose (because the new type was Python) and leak the old .so + * handle; the converse direction would force-free a Python instance's + * native_plugin (which is NULL) but leave python_plugin orphaned. + * + * After this loop every slot 0..old plugin_count-1 is zeroed; we can + * safely rebuild from configs[] without worrying about stale state. + * This function is only called from load_plc_program (post-STOP) and + * plc_main.c boot, both of which guarantee no plugin is running, so + * dlclose is safe. + * + * GIL: this function does Python work in two places — + * 1) teardown_plugin_instance → python_plugin_cleanup (Py_XDECREF, + * PyObject_CallFunctionObjArgs) for any old Python slot; + * 2) python_plugin_get_symbols (PyImport_ImportModule, etc.) for + * each new Python entry in the rebuild loop. + * Both require the GIL. plc_main releases the GIL after the initial + * plugin init, so the second update_config call (from + * load_plc_program) lands here without it. + * + * The teardown loop is the only stage that strictly needs an explicit + * ensure — if Python is initialized and we have Python plugins to + * tear down, we MUST hold the GIL or Py_XDECREF will SIGSEGV. The + * rebuild loop's python_plugin_get_symbols handles the cold-start + * case itself (it calls Py_Initialize if needed and is implicitly + * GIL-holding after that), so for that loop we just need to make + * sure we don't release the GIL we acquired here. */ + PyGILState_STATE plugin_gstate = PyGILState_LOCKED; + int plugin_have_gil = Py_IsInitialized(); + if (plugin_have_gil) + { + plugin_gstate = PyGILState_Ensure(); + } + + int old_plugin_count = driver->plugin_count; + for (int w = 0; w < old_plugin_count; w++) + { + teardown_plugin_instance(&driver->plugins[w]); + } + + /* Reset has_python_plugin before rebuilding — it'll be set again below + * for any Python entries in the new config. Without resetting, removing + * the last Python plugin from plugins.conf would leave the flag at 1 + * and cause unnecessary GIL acquires throughout the driver. */ + has_python_plugin = 0; + + int load_failures = 0; driver->plugin_count = config_count; - has_python_plugin = 0; + for (int w = 0; w < config_count; w++) { - memcpy(&driver->plugins[w].config, &configs[w], sizeof(plugin_config_t)); + plugin_instance_t *plugin = &driver->plugins[w]; + // Slot already zeroed by the teardown loop (or never used). + memcpy(&plugin->config, &configs[w], sizeof(plugin_config_t)); + if (configs[w].type == PLUGIN_TYPE_PYTHON) { has_python_plugin = 1; + /* Re-import Python module symbols here. The teardown loop + * above ran python_plugin_cleanup, which zeros python_plugin. + * Without re-importing, plugin_driver_init's Python branch + * (which requires plugin->python_plugin && pFuncInit) would + * silently skip every Python plugin on the second invocation + * of update_config — the modbus_slave / modbus_master / opcua + * plugins would never re-init after a PLC restart. + * + * python_plugin_get_symbols handles cold-start itself: if + * Python isn't initialized yet, it calls Py_Initialize which + * implicitly puts the current thread in possession of the GIL, + * so subsequent Python plugins in this loop also run safely + * without an explicit ensure. */ + if (plugin->config.path[0] != '\0') + { + if (python_plugin_get_symbols(plugin) != 0) + { + if (plugin->config.enabled) + { + log_error("[PLUGIN] enabled Python plugin '%s' failed to load symbols", + configs[w].name); + ++load_failures; + } + else + { + log_warn("[PLUGIN] disabled Python plugin '%s' has no loadable module", + configs[w].name); + } + } + } + } + else if (configs[w].type == PLUGIN_TYPE_NATIVE) + { + if (native_plugin_get_symbols(plugin) != 0) + { + if (plugin->config.enabled) + { + /* An enabled native plugin failed to load its .so. + * This is the fix for "update_config failure not + * surfaced": previously this was a log_warn and the + * caller proceeded into init/start with native_plugin + * == NULL, silently dropping cycle hooks. Now we mark + * the load as failed so load_plc_program can surface + * it as an ERROR state instead of a silent no-op. */ + log_error("[PLUGIN] enabled native plugin '%s' failed to load symbols", + configs[w].name); + ++load_failures; + } + else + { + log_warn("[PLUGIN] disabled native plugin '%s' has no loadable .so", + configs[w].name); + } + } } } - return 0; + + if (plugin_have_gil) + { + PyGILState_Release(plugin_gstate); + } + + return (load_failures > 0) ? -1 : 0; } -int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file) +int plugin_driver_append_config(plugin_driver_t *driver, const char *config_file) { if (!driver || !config_file) { return -1; } - plugin_driver_update_config(driver, config_file); + if (access(config_file, F_OK) != 0) + { + /* File absent is not an error — VPP is optional. */ + return 0; + } - // Now retrieve the function symbols and initialize - // struct plugin_instance_t para cada plugin. - for (int i = 0; i < driver->plugin_count; i++) + plugin_config_t configs[MAX_PLUGINS]; + int config_count = parse_plugin_config(config_file, configs, MAX_PLUGINS); + if (config_count < 0) { - plugin_instance_t *plugin = &driver->plugins[i]; + return -1; + } - if (plugin->config.type == PLUGIN_TYPE_PYTHON) + int load_failures = 0; + + for (int w = 0; w < config_count; w++) + { + /* Skip if we'd overflow the driver's plugin array. */ + if (driver->plugin_count >= MAX_PLUGINS) { - if (python_plugin_get_symbols(plugin) != 0) - { - log_error("Failed to get Python plugin symbols for: %s", plugin->config.path); - return -1; - } + log_warn("[PLUGIN] plugin_driver_append_config: MAX_PLUGINS reached, skipping '%s'", + configs[w].name); + break; } - else if (plugin->config.type == PLUGIN_TYPE_NATIVE) + + plugin_instance_t *plugin = &driver->plugins[driver->plugin_count]; + memset(plugin, 0, sizeof(*plugin)); + memcpy(&plugin->config, &configs[w], sizeof(plugin_config_t)); + driver->plugin_count++; + + if (configs[w].type == PLUGIN_TYPE_NATIVE) { if (native_plugin_get_symbols(plugin) != 0) { - log_error("Failed to get native plugin symbols for: %s", plugin->config.path); - return -1; + if (plugin->config.enabled) + { + log_error("[PLUGIN] enabled VPP plugin '%s' failed to load symbols", + configs[w].name); + ++load_failures; + } + else + { + log_warn("[PLUGIN] disabled VPP plugin '%s' has no loadable .so", + configs[w].name); + } } } } - return 0; + return (load_failures > 0) ? -1 : 0; +} + +int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file) +{ + if (!driver || !config_file) + { + return -1; + } + + /* plugin_driver_update_config now performs the full teardown + rebuild, + * including symbol loading for both Python and native plugins. The + * previous post-update_config loop here would re-call the *get_symbols + * functions on already-loaded slots — those allocate fresh bundles and + * overwrite the pointer, leaking the bundle that update_config just + * created. Just forward the return code. */ + return plugin_driver_update_config(driver, config_file); } // Send to plugin init function all args @@ -303,6 +573,7 @@ int plugin_driver_init(plugin_driver_t *driver) return -1; } Py_DECREF(result); + plugin->initialized = 1; } else if (plugin->config.type == PLUGIN_TYPE_NATIVE && plugin->native_plugin && plugin->native_plugin->init) @@ -338,6 +609,7 @@ int plugin_driver_init(plugin_driver_t *driver) // Free the args after successful initialization free_structured_args(args); + plugin->initialized = 1; } } @@ -349,6 +621,39 @@ int plugin_driver_init(plugin_driver_t *driver) return 0; } +int plugin_driver_cleanup_init(plugin_driver_t *driver) +{ + if (!driver) return 0; + + PyGILState_STATE local_gstate = PyGILState_LOCKED; + int have_gil = has_python_plugin && Py_IsInitialized(); + if (have_gil) local_gstate = PyGILState_Ensure(); + + int cleaned = 0; + /* Reverse order so dependent plugins (declared later, depend on + * resources owned by earlier plugins) tear down first. */ + for (int i = driver->plugin_count - 1; i >= 0; --i) + { + plugin_instance_t *plugin = &driver->plugins[i]; + if (!plugin->initialized) continue; + + if (plugin->config.type == PLUGIN_TYPE_PYTHON && plugin->python_plugin) + { + python_plugin_cleanup(plugin); + } + else if (plugin->config.type == PLUGIN_TYPE_NATIVE && plugin->native_plugin && + plugin->native_plugin->cleanup) + { + plugin->native_plugin->cleanup(); + } + plugin->initialized = 0; + ++cleaned; + } + + if (have_gil) PyGILState_Release(local_gstate); + return cleaned; +} + // Call the thread function for each plugin int plugin_driver_start(plugin_driver_t *driver) { @@ -555,18 +860,20 @@ void plugin_driver_destroy(plugin_driver_t *driver) for (int i = 0; i < driver->plugin_count; i++) { plugin_instance_t *plugin = &driver->plugins[i]; - if (plugin->python_plugin && python_initialized) + if (plugin->python_plugin && python_initialized && plugin->initialized) { python_plugin_cleanup(plugin); + plugin->initialized = 0; } if (plugin->native_plugin) { - // Call cleanup function if available - if (plugin->native_plugin->cleanup) + // Call cleanup function if available, but only if init() ran. + if (plugin->initialized && plugin->native_plugin->cleanup) { plugin->native_plugin->cleanup(); log_info("Native plugin %s cleaned up successfully", plugin->config.name); } + plugin->initialized = 0; // Close the shared library handle if (plugin->native_plugin->handle) { @@ -646,11 +953,18 @@ void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t * args->bool_memory = bool_memory; // Initialize mutex functions - args->mutex_take = plugin_mutex_take; - args->mutex_give = plugin_mutex_give; - args->get_var_list = get_var_list; - args->get_var_size = get_var_size; - args->get_var_count = get_var_count; + args->mutex_take = plugin_mutex_take; + args->mutex_give = plugin_mutex_give; + // STruC++ debugger surface — replaces the MatIEC-era + // get_var_list / get_var_size / get_var_count flat-index API. + // Each thunk forwards to the corresponding ext_strucpp_debug_* + // function pointer resolved from the program .so. NULL-safe. + args->debug_array_count = plugin_debug_array_count; + args->debug_elem_count = plugin_debug_elem_count; + args->debug_size = plugin_debug_size; + args->debug_read = plugin_debug_read; + args->debug_set = plugin_debug_set; + args->debug_write = plugin_debug_write; // Set buffer mutex from driver args->buffer_mutex = &driver->buffer_mutex; @@ -679,8 +993,13 @@ void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t * args->journal_write_dint = plugin_journal_write_dint; args->journal_write_lint = plugin_journal_write_lint; - // PLC base tick time - args->common_ticktime_ns = ext_common_ticktime__ ? *ext_common_ticktime__ : 0; + // Plugin-initiated async PLC stop (for unrecoverable hardware faults). + args->request_plc_stop = plugin_request_plc_stop; + + // PLC base tick time. Runtime owns base_tick_ns; on first plugin init + // (before symbols_init) it carries the 20 ms default, so plugins must + // guard against the value being smaller than their needed resolution. + args->base_tick_ns = base_tick_ns; // printf("[PLUGIN]: Runtime args initialized:\n"); // printf("[PLUGIN]: buffer_size = %d\n", args->buffer_size); @@ -993,6 +1312,10 @@ int native_plugin_get_symbols(plugin_instance_t *plugin) plugin->config.path); } + native_bundle->get_stats = (plugin_get_stats_func_t)dlsym(handle, "get_stats"); + // get_stats is fully optional — plugins that don't publish statistics + // simply don't export it. No warning. + // Store the native bundle and handle in the plugin instance plugin->native_plugin = native_bundle; @@ -1004,6 +1327,7 @@ int native_plugin_get_symbols(plugin_instance_t *plugin) log_info(" - cycle_end: %s", native_bundle->cycle_end ? "(PASS)" : "(FAIL)"); log_info(" - cleanup: %s", native_bundle->cleanup ? "(PASS)" : "(FAIL)"); log_info(" - execute_command: %s", native_bundle->execute_command ? "(PASS)" : "(FAIL)"); + log_info(" - get_stats: %s", native_bundle->get_stats ? "(PASS)" : "(FAIL)"); return 0; } @@ -1105,6 +1429,141 @@ int plugin_driver_execute_command(plugin_driver_t *driver, const char *plugin_na return -1; } +// =================================================================== +// Plugin-contributed statistics aggregation +// =================================================================== +// +// Called from the STATS response path. Takes an already-formatted JSON +// response ending in "}\n" (or "}"), asks each native plugin that +// exports get_stats to produce a JSON object snippet, and splices them +// into a "plugin_stats" member before the closing brace. +// +// Per-plugin budget: PLUGIN_STATS_SLOT_BUDGET bytes. +// Combined budget: PLUGIN_STATS_TOTAL_BUDGET bytes. +// Output is best-effort: malformed plugin output (doesn't start with +// '{' and end with '}') is silently dropped, overflow truncates, and +// the core STATS response is always preserved. +#define PLUGIN_STATS_SLOT_BUDGET 1024 +#define PLUGIN_STATS_TOTAL_BUDGET 8192 + +size_t plugin_driver_append_stats_json(plugin_driver_t *driver, char *buffer, + size_t buffer_size) +{ + if (!buffer || buffer_size == 0) + return 0; + + size_t len = strlen(buffer); + + // Detect and strip a trailing newline — we'll re-add it after splicing. + int had_newline = 0; + if (len > 0 && buffer[len - 1] == '\n') + { + had_newline = 1; + buffer[--len] = '\0'; + } + + // Expect the core STATS payload to end in '}'. If it doesn't, the + // response is malformed and we won't risk making it worse. + if (len == 0 || buffer[len - 1] != '}') + { + if (had_newline && len + 1 < buffer_size) + { + buffer[len] = '\n'; + buffer[len + 1] = '\0'; + len++; + } + return len; + } + + if (!driver || driver->plugin_count == 0) + { + if (had_newline && len + 1 < buffer_size) + { + buffer[len] = '\n'; + buffer[len + 1] = '\0'; + len++; + } + return len; + } + + // Build the ,"plugin_stats":{...} section in a scratch buffer so we + // can commit-or-rollback atomically if it would overflow the response. + char scratch[PLUGIN_STATS_TOTAL_BUDGET]; + size_t spos = 0; + int emitted = 0; + + for (int i = 0; i < driver->plugin_count; i++) + { + plugin_instance_t *p = &driver->plugins[i]; + if (p->config.type != PLUGIN_TYPE_NATIVE) + continue; + if (!p->native_plugin || !p->native_plugin->get_stats) + continue; + + char slot[PLUGIN_STATS_SLOT_BUDGET]; + slot[0] = '\0'; + if (p->native_plugin->get_stats(slot, sizeof(slot)) != 0) + continue; + + size_t slen = strnlen(slot, sizeof(slot)); + if (slen < 2 || slot[0] != '{' || slot[slen - 1] != '}') + continue; // malformed — drop silently + + int n = snprintf(scratch + spos, sizeof(scratch) - spos, "%s\"%s\":%s", + emitted ? "," : "", p->config.name, slot); + if (n < 0 || (size_t)n >= sizeof(scratch) - spos) + break; // scratch full; commit what we have + + spos += (size_t)n; + emitted = 1; + } + + if (!emitted) + { + if (had_newline && len + 1 < buffer_size) + { + buffer[len] = '\n'; + buffer[len + 1] = '\0'; + len++; + } + return len; + } + + // Splice: overwrite the closing '}' with ,"plugin_stats":{...}} and + // re-append the newline if present. + size_t insert_pos = len - 1; + int n = snprintf(buffer + insert_pos, buffer_size - insert_pos, + ",\"plugin_stats\":{%s}}%s", scratch, had_newline ? "\n" : ""); + if (n < 0) + { + // snprintf failure — restore newline and bail. + if (had_newline && len + 1 < buffer_size) + { + buffer[len] = '\n'; + buffer[len + 1] = '\0'; + len++; + } + return len; + } + if ((size_t)n >= buffer_size - insert_pos) + { + // Would overflow the response buffer; roll back by restoring the '}' + // and the newline. + buffer[insert_pos] = '}'; + buffer[insert_pos + 1] = '\0'; + len = insert_pos + 1; + if (had_newline && len + 1 < buffer_size) + { + buffer[len] = '\n'; + buffer[len + 1] = '\0'; + len++; + } + return len; + } + + return insert_pos + (size_t)n; +} + // Cleanup Python plugin static void python_plugin_cleanup(plugin_instance_t *plugin) { diff --git a/core/src/drivers/plugin_driver.h b/core/src/drivers/plugin_driver.h index 8e7228e2..57daeb2c 100644 --- a/core/src/drivers/plugin_driver.h +++ b/core/src/drivers/plugin_driver.h @@ -6,6 +6,10 @@ #include "plugin_types.h" #include "python_plugin_bridge.h" +#ifdef __cplusplus +extern "C" { +#endif + // Maximum number of plugins #define MAX_PLUGINS 16 @@ -23,6 +27,11 @@ typedef void (*plugin_cycle_end_func_t)(void); typedef void (*plugin_cleanup_func_t)(void); typedef int (*plugin_execute_command_func_t)(const char *command_json, char *response, size_t response_size); +// Optional: fills `out` with a JSON object describing plugin-specific +// statistics. Called from the STATS response path so it MUST be +// non-blocking (atomic reads or trivial copies only). +// Return 0 on success; any other value means "skip me this cycle." +typedef int (*plugin_get_stats_func_t)(char *out, size_t out_size); typedef struct { @@ -34,6 +43,7 @@ typedef struct plugin_cycle_end_func_t cycle_end; plugin_cleanup_func_t cleanup; plugin_execute_command_func_t execute_command; + plugin_get_stats_func_t get_stats; } plugin_funct_bundle_t; // Plugin instance structure @@ -44,6 +54,12 @@ typedef struct plugin_instance_s plugin_funct_bundle_t *native_plugin; // pthread_t thread; int running; + /* Set after a successful init() call; cleared by cleanup. Tracked + * separately from `running` so a partial init failure (e.g., + * pthread_create on the cycle thread fails AFTER plugin_driver_init + * succeeded) can roll back only the plugins that actually got + * initialised, not those still untouched. */ + int initialized; plugin_config_t config; } plugin_instance_t; @@ -59,7 +75,20 @@ typedef struct plugin_driver_t *plugin_driver_create(void); int plugin_driver_load_config(plugin_driver_t *driver, const char *config_file); int plugin_driver_update_config(plugin_driver_t *driver, const char *config_file); +/** Append plugins from a secondary config file without tearing down the + * plugins already loaded by plugin_driver_update_config. Used to load + * VPP plugins from vpp_plugins.conf after built-ins from plugins.conf. + * Returns 0 on success, -1 if any enabled plugin fails to load its .so. */ +int plugin_driver_append_config(plugin_driver_t *driver, const char *config_file); int plugin_driver_init(plugin_driver_t *driver); +/* Mirror of plugin_driver_init: walks plugins[] in reverse order and calls + * the matching cleanup hook on every plugin whose `initialized` flag is + * set, then clears the flag. Used to roll back a partial init when a + * later step (e.g., spawning the cycle thread) fails — without this, a + * subsequent INIT cycle re-runs plugin init() on top of half-allocated + * state and duplicates threads/sockets. Safe to call when no plugins are + * initialised. Returns the count of plugins it cleaned up. */ +int plugin_driver_cleanup_init(plugin_driver_t *driver); int plugin_driver_start(plugin_driver_t *driver); int plugin_driver_stop(plugin_driver_t *driver); void plugin_driver_destroy(plugin_driver_t *driver); @@ -76,6 +105,14 @@ void plugin_driver_cycle_end(plugin_driver_t *driver); int plugin_driver_execute_command(plugin_driver_t *driver, const char *plugin_name, const char *command_json, char *response, size_t response_size); +// Splice plugin-contributed statistics into an already-formatted STATS +// response. Walks loaded native plugins, calls each get_stats, and +// inserts a "plugin_stats":{...} member before the closing `}` of the +// existing STATS JSON. A trailing newline in `buffer` is preserved. +// No-op if no plugin provides stats. Returns the new string length. +size_t plugin_driver_append_stats_json(plugin_driver_t *driver, char *buffer, + size_t buffer_size); + // Python plugin functions int python_plugin_get_symbols(plugin_instance_t *plugin); @@ -87,4 +124,8 @@ void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t * int plugin_index); void free_structured_args(plugin_runtime_args_t *args); +#ifdef __cplusplus +} +#endif + #endif // PLUGIN_DRIVER_H diff --git a/core/src/drivers/plugin_types.h b/core/src/drivers/plugin_types.h index 8bc1c757..6c9d3c15 100644 --- a/core/src/drivers/plugin_types.h +++ b/core/src/drivers/plugin_types.h @@ -17,6 +17,7 @@ #include "../lib/iec_types.h" #include +#include #include /** @@ -51,6 +52,54 @@ typedef int (*plugin_journal_write_int_func_t)(int type, int index, int value); typedef int (*plugin_journal_write_dint_func_t)(int type, int index, unsigned int value); typedef int (*plugin_journal_write_lint_func_t)(int type, int index, unsigned long long value); +/** + * @brief Variable-access function pointer types (STruC++ debugger surface) + * + * These wrap the strucpp_debug_* exports of the loaded program .so: + * + * debug_array_count() / debug_elem_count(arr) — table sizes + * debug_size(arr, elem) — bytes consumed by force/read/write + * debug_read(arr, elem, dest) — read current value (respects forcing) + * debug_set(arr, elem, forcing, bytes, len) — force / unforce + * debug_write(arr, elem, bytes, len) — soft write (respects existing force) + * + * Variables are addressed by (arr, elem) — array and element indices into + * the per-project debug Entry tables emitted by STruC++ codegen. The map + * from human-readable names to (arr, elem) lives in debug-map.json, + * produced by the editor at compile time. Plugins do not interact with + * the map directly; the editor resolves user-selected variables and + * writes the (arr, elem) tuples into each plugin's per-plugin config. + * + * All five thunks are NULL-safe: they short-circuit when no program is + * loaded yet (ext_strucpp_debug_* still nullptr after symbols_init has + * not run). debug_array_count returns 0 in that state. + */ +typedef uint8_t (*plugin_debug_array_count_func_t)(void); +typedef uint16_t (*plugin_debug_elem_count_func_t)(uint8_t arr); +typedef uint16_t (*plugin_debug_size_func_t)(uint8_t arr, uint16_t elem); +typedef uint16_t (*plugin_debug_read_func_t)(uint8_t arr, uint16_t elem, + uint8_t *dest); +typedef uint8_t (*plugin_debug_set_func_t)(uint8_t arr, uint16_t elem, + bool forcing, + const uint8_t *bytes, uint16_t len); +typedef uint8_t (*plugin_debug_write_func_t)(uint8_t arr, uint16_t elem, + const uint8_t *bytes, uint16_t len); + +/** + * @brief PLC stop request from a plugin + * + * Asks the runtime to transition from RUNNING to STOPPED through the normal + * shutdown path (stop all plugins cleanly, unload program, clear image + * tables). Used by plugins that detect unrecoverable hardware faults — e.g. + * a fieldbus plugin losing communication with its backplane — so the PLC + * scan thread stops consuming stale inputs. + * + * The call is non-blocking: the runtime spawns a worker thread for the + * transition and returns immediately. `reason` is logged at error level. + * Safe to call from any plugin thread. + */ +typedef void (*plugin_request_plc_stop_func_t)(const char *reason); + /** * @brief Runtime buffer access structure for plugins * @@ -88,10 +137,17 @@ typedef struct int (*mutex_give)(pthread_mutex_t *mutex); pthread_mutex_t *buffer_mutex; - /* Variable access functions */ - void (*get_var_list)(size_t num_vars, size_t *indexes, void **result); - size_t (*get_var_size)(size_t idx); - uint16_t (*get_var_count)(void); + /* STruC++ debugger variable-access surface. + * Replaces the MatIEC-era flat-index API (get_var_list / + * get_var_size / get_var_count). Plugins like OPC-UA receive + * pre-resolved (arr, elem) tuples from the editor in their + * per-plugin config and forward them through these thunks. */ + plugin_debug_array_count_func_t debug_array_count; + plugin_debug_elem_count_func_t debug_elem_count; + plugin_debug_size_func_t debug_size; + plugin_debug_read_func_t debug_read; + plugin_debug_set_func_t debug_set; + plugin_debug_write_func_t debug_write; /* Plugin configuration */ char plugin_specific_config_file_path[256]; @@ -113,8 +169,13 @@ typedef struct plugin_journal_write_dint_func_t journal_write_dint; plugin_journal_write_lint_func_t journal_write_lint; - /* PLC base tick time in nanoseconds (GCD of all IEC task intervals) */ - unsigned long long common_ticktime_ns; + /* Async request to stop the whole PLC — see plugin_request_plc_stop_func_t. */ + plugin_request_plc_stop_func_t request_plc_stop; + + /* PLC base tick time in nanoseconds (GCD of all IEC task intervals). + * Populated when the runtime initializes the plugin; may be 0 if + * symbols are not yet resolved (plugin must guard against zero). */ + unsigned long long base_tick_ns; } plugin_runtime_args_t; #endif /* PLUGIN_TYPES_H */ diff --git a/core/src/drivers/plugin_utils.c b/core/src/drivers/plugin_utils.c deleted file mode 100644 index 6eedd883..00000000 --- a/core/src/drivers/plugin_utils.c +++ /dev/null @@ -1,59 +0,0 @@ -#include "plugin_utils.h" -#include "../plc_app/image_tables.h" -#include -#include -#include - -// Wrapper function to get list of variable addresses -// Returns NULL for all addresses if no PLC program is loaded -void get_var_list(size_t num_vars, size_t *indexes, void **result) -{ - // Validate input parameters - if (!indexes || !result || num_vars == 0) - { - return; - } - - // Check if PLC program is loaded (function pointers are set) - if (!ext_get_var_count || !ext_get_var_addr) - { - for (size_t i = 0; i < num_vars; i++) - { - result[i] = NULL; - } - return; - } - - for (size_t i = 0; i < num_vars; i++) - { - size_t idx = indexes[i]; - if (idx >= ext_get_var_count()) - { - result[i] = NULL; - } - else - { - result[i] = ext_get_var_addr(idx); - } - } -} - -// Returns 0 if no PLC program is loaded -size_t get_var_size(size_t idx) -{ - if (!ext_get_var_size) - { - return 0; - } - return ext_get_var_size(idx); -} - -// Returns 0 if no PLC program is loaded -uint16_t get_var_count(void) -{ - if (!ext_get_var_count) - { - return 0; - } - return ext_get_var_count(); -} \ No newline at end of file diff --git a/core/src/drivers/plugin_utils.h b/core/src/drivers/plugin_utils.h deleted file mode 100644 index 52a6ebfc..00000000 --- a/core/src/drivers/plugin_utils.h +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef PLUGIN_UTILS_H -#define PLUGIN_UTILS_H - -#include -#include - -void get_var_list(size_t num_vars, size_t *indexes, void **result); -size_t get_var_size(size_t idx); -uint16_t get_var_count(void); - -#endif // PLUGIN_UTILS_H \ No newline at end of file diff --git a/core/src/drivers/plugins/native/ethercat/cjson/cJSON.c b/core/src/drivers/plugins/native/cjson/cJSON.c similarity index 100% rename from core/src/drivers/plugins/native/ethercat/cjson/cJSON.c rename to core/src/drivers/plugins/native/cjson/cJSON.c diff --git a/core/src/drivers/plugins/native/ethercat/cjson/cJSON.h b/core/src/drivers/plugins/native/cjson/cJSON.h similarity index 100% rename from core/src/drivers/plugins/native/ethercat/cjson/cJSON.h rename to core/src/drivers/plugins/native/cjson/cJSON.h diff --git a/core/src/drivers/plugins/native/ethercat/CMakeLists.txt b/core/src/drivers/plugins/native/ethercat/CMakeLists.txt index a2e6d000..40e5d57f 100644 --- a/core/src/drivers/plugins/native/ethercat/CMakeLists.txt +++ b/core/src/drivers/plugins/native/ethercat/CMakeLists.txt @@ -121,11 +121,11 @@ endif() add_subdirectory(${SOEM_DIR} ${CMAKE_CURRENT_BINARY_DIR}/soem) # ============================================================================= -# cJSON Library (embedded for JSON configuration parsing) +# cJSON Library (shared utility for all native plugins) # ============================================================================= set(CJSON_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/cjson/cJSON.c + ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson/cJSON.c ) # ============================================================================= @@ -157,7 +157,7 @@ add_library(ethercat_plugin SHARED target_include_directories(ethercat_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}/cjson + ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson ${OPENPLC_ROOT}/core/src/drivers ${OPENPLC_ROOT}/core/src/drivers/plugins/native ${OPENPLC_ROOT}/core/src/lib diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_config.c b/core/src/drivers/plugins/native/ethercat/ethercat_config.c index 42850b05..8c463f0e 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_config.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_config.c @@ -309,8 +309,7 @@ static void parse_master_section(const cJSON *master, ecat_master_config_t *conf 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)); - safe_strcpy(config->task_name, get_string(master, "task_name", ""), sizeof(config->task_name)); - config->task_cycle_time_us = get_int(master, "task_cycle_time_us", 0); + config->task_priority = get_int(master, "task_priority", 90); config->safe_close = get_bool(master, "safe_close", true); } @@ -715,8 +714,7 @@ void ecat_config_init_defaults(ecat_config_t *config) 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_name[0] = '\0'; - config->master.task_cycle_time_us = 0; + config->master.task_priority = 90; config->master.safe_close = true; /* Diagnostics defaults */ diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_config.h b/core/src/drivers/plugins/native/ethercat/ethercat_config.h index 6e39cd4b..9b4e0f87 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_config.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_config.h @@ -222,8 +222,10 @@ typedef struct { int receive_timeout_us; int watchdog_timeout_cycles; char log_level[8]; - char task_name[ECAT_MAX_NAME_LEN]; - int task_cycle_time_us; + /** 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; @@ -407,26 +409,22 @@ typedef struct { */ /** - * @brief EWMA shift for the avg_*_ns counters in ecat_cycle_diag_t. + * @brief Target wall-clock window for the time-based EWMA averages, in ns. * - * The moving average update is: avg += (sample - avg) / (1 << SHIFT), - * an exponentially-weighted moving average with weight 1 / 2^SHIFT per - * sample. Effective window is ~2^SHIFT samples. + * 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. * - * SHIFT=5 -> window of 32 samples: - * - cycle 250 us -> 8 ms window (smooths PDO-cycle jitter) - * - cycle 500 us -> 16 ms window - * - cycle 1 ms -> 32 ms window (smooths transient bus events) + * Per-cycle update: sum += sample - sum / N + * Read: avg = sum / N * - * Trade-off: smaller SHIFT is more responsive but noisier; larger SHIFT - * smooths more but lags behind real drift. 5 is the sweet spot for - * RT diagnostics where the operator wants "is the bus healthy now?", - * not "what was the historical mean?". - * - * This replaces the integer Welford running mean previously used, which - * stalls under integer division once cycle_count grows past ~10^7. + * 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_EWMA_SHIFT 5 +#define ECAT_AVG_TARGET_WINDOW_NS 2000000000LL /** * @brief Per-interface NIC tuning state captured by ecat_iface_state_apply(). @@ -455,32 +453,56 @@ typedef struct { /** * @brief Per-cycle timing diagnostics * - * Updated lock-free by cycle_start_single() in the PLC thread. - * Single-writer (PLC), 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. - * - * Measures the EtherCAT bus exchange (ecx_send_processdata + - * ecx_receive_processdata). The memcpy work in - * ecat_io_write_outputs_fast / ecat_io_read_inputs_fast is intentionally - * not measured: it is dozens of nanoseconds for typical channel maps and - * dwarfed by the bus exchange itself. The JSON exposed to the Editor - * keeps the legacy field names (avg_cycle_us, max_cycle_us, - * max_exchange_us, etc.) — they all reflect this same bus_cycle_ns - * measurement. - * - * avg_bus_cycle_ns is an EWMA tracking value (see ECAT_AVG_EWMA_SHIFT), - * not a historical mean. + * 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 */ - _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; /* EWMA (see ECAT_AVG_EWMA_SHIFT) */ + + /* 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; /* @@ -608,7 +630,7 @@ typedef struct { int expected_wkc; int receive_timeout_us; - /* Diagnostics (updated by cycle_start in PLC thread) */ + /* Diagnostics (updated by the bus thread) */ ecat_cycle_diag_t diag; _Atomic(int) consecutive_wkc_errors; _Atomic(int) recovery_attempts; @@ -618,7 +640,6 @@ typedef struct { * configuration recovery failures in the operator UI. */ _Atomic(uint32_t) recovery_writestate_failures; uint64_t cycle_counter; - unsigned int tick_divisor; /* Per-slave snapshot for queries via execute_command. * @@ -650,6 +671,19 @@ typedef struct { _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 diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c index f2a13773..5b893c44 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.c @@ -16,14 +16,30 @@ * Phase 3: Synchronous process data exchange within PLC scan cycle * * Architecture: - * - Process data exchange runs synchronously inside the PLC scan cycle - * via cycle_start/cycle_end hooks, sharing the PLC thread's timing. - * - cycle_start: writes outputs, exchanges process data, reads inputs - * - cycle_end: (no-op, outputs are sent at the start of the next cycle) - * - No separate EtherCAT thread or shadow buffer is needed. + * - 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. */ +/* _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 @@ -41,7 +57,12 @@ #include "ethercat_master.h" #include "ethercat_io.h" #include "soem/soem.h" /* osal_get_monotonic_time, ec_timet */ -#include "cjson/cJSON.h" /* JSON parsing for execute_command */ +#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); /* * ============================================================================= @@ -143,6 +164,8 @@ static void diag_reset(ecat_cycle_diag_t *d) { memset(d, 0, sizeof(*d)); atomic_store_explicit(&d->min_bus_cycle_ns, UINT64_MAX, memory_order_relaxed); + atomic_store_explicit(&d->min_period_ns, UINT64_MAX, memory_order_relaxed); + atomic_store_explicit(&d->min_latency_ns, INT64_MAX, memory_order_relaxed); } /* @@ -681,6 +704,17 @@ static int start_single_master(ecat_master_instance_t *inst) 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 */ @@ -709,10 +743,23 @@ static int start_single_master(ecat_master_instance_t *inst) * 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); + return -1; + } + plugin_logger_info(&g_logger, "Master '%s': [state: OPERATIONAL] EtherCAT master started " - "(synchronous exchange, monitor=%s)", - inst->name, ECAT_ENABLE_MONITOR_THREAD ? "enabled" : "disabled"); + "(dedicated bus thread, cycle=%d us, monitor=%s)", + inst->name, inst->config.master.cycle_time_us, + ECAT_ENABLE_MONITOR_THREAD ? "enabled" : "disabled"); return 0; } @@ -737,6 +784,17 @@ static void stop_single_master(ecat_master_instance_t *inst) "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)) { @@ -754,8 +812,8 @@ static void stop_single_master(ecat_master_instance_t *inst) 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_avg = - atomic_load_explicit(&inst->diag.avg_bus_cycle_ns, 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 = @@ -763,6 +821,9 @@ static void stop_single_master(ecat_master_instance_t *inst) 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); @@ -773,7 +834,7 @@ static void stop_single_master(ecat_master_instance_t *inst) (unsigned long long)total_cycles, (unsigned long long)final_cycle_count, (unsigned long long)final_wkc_errors, - (long long)(final_avg / 1000), + (long long)(final_avg_ns / 1000), min_us, (unsigned long long)(final_max / 1000)); } @@ -802,59 +863,84 @@ static void stop_single_master(ecat_master_instance_t *inst) } /** - * @brief Run one cycle for a single master instance + * @brief Perform one EtherCAT cycle for a single master instance * - * Performs synchronous EtherCAT process data exchange: - * 1. Writes PLC outputs (from previous cycle) into the real IOmap - * 2. Exchanges process data with all slaves via SOEM - * 3. Reads received inputs from the IOmap into PLC input buffers - * 4. Tracks WKC errors (no blocking SOEM calls) + * 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): * - * @param inst Per-master instance + * 1. lock buffer_mutex → copy PLC outputs into IOmap → unlock + * 2. SOEM exchange (no mutex held — IEC tasks can run during it) + * 3. lock buffer_mutex → copy IOmap inputs into PLC buffers → unlock + * 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 void cycle_start_single(ecat_master_instance_t *inst) +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; - - /* Task-based scheduling: skip cycles that don't align with the task interval. - * tick_divisor is always >= 1; when 1 the modulo is always 0 (no skip). */ - if ((inst->cycle_counter % inst->tick_divisor) != 0) { - inst->cycle_counter++; - return; - } + return false; uint8_t *iomap = ecat_master_get_iomap(inst); if (!iomap) - return; + return false; #if ECAT_ENABLE_MONITOR_THREAD /* If the monitor thread is holding soem_lock (state check or recovery), - * yield this cycle. The PLC keeps running with stale I/O data for one + * 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; + return false; } #endif - /* 1. Write PLC outputs to IOmap (from previous cycle's computation) */ + ec_timet t_exch_start, t_exch_end; + + /* 1. Lock briefly, snapshot outputs from PLC buffers into the IOmap. + * This is a pure memcpy — microseconds — so we don't hold the + * image-tables mutex across the SOEM exchange. */ + if (g_runtime_args.mutex_take && g_runtime_args.buffer_mutex) { + g_runtime_args.mutex_take(g_runtime_args.buffer_mutex); + } ecat_io_write_outputs_fast(&inst->transfer_list, iomap); + if (g_runtime_args.mutex_give && g_runtime_args.buffer_mutex) { + g_runtime_args.mutex_give(g_runtime_args.buffer_mutex); + } - /* 2. Exchange process data with slaves (synchronous) */ - ec_timet t0, t1; - osal_get_monotonic_time(&t0); + /* 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(&t1); + osal_get_monotonic_time(&t_exch_end); - uint64_t exchange_ns = elapsed_ns(&t0, &t1); + uint64_t exchange_ns = elapsed_ns(&t_exch_start, &t_exch_end); - /* 3. Read received inputs into PLC buffers */ + /* 3. Lock again briefly, copy received inputs from IOmap into PLC + * buffers. */ + if (g_runtime_args.mutex_take && g_runtime_args.buffer_mutex) { + g_runtime_args.mutex_take(g_runtime_args.buffer_mutex); + } ecat_io_read_inputs_fast(&inst->transfer_list, iomap); + if (g_runtime_args.mutex_give && g_runtime_args.buffer_mutex) { + g_runtime_args.mutex_give(g_runtime_args.buffer_mutex); + } #if ECAT_ENABLE_MONITOR_THREAD pthread_mutex_unlock(&inst->soem_lock); @@ -882,17 +968,16 @@ static void cycle_start_single(ecat_master_instance_t *inst) atomic_fetch_add_explicit(&inst->diag.cycle_count, 1, memory_order_relaxed); - /* EWMA: avg += (sample - avg) / 2^SHIFT. Tracks recent-window jitter - * instead of a historical mean (which the previous integer-Welford - * loop stalled at once cycle_count grew large). Division (not signed - * right shift) keeps the math portable -- the compiler emits the same - * sar sequence on supported targets without invoking the IDB on - * negative-operand right shift. */ - int64_t cur_avg = atomic_load_explicit(&inst->diag.avg_bus_cycle_ns, + /* 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); - int64_t delta = (int64_t)exchange_ns - cur_avg; - cur_avg += delta / (1 << ECAT_AVG_EWMA_SHIFT); - atomic_store_explicit(&inst->diag.avg_bus_cycle_ns, cur_avg, + 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. */ @@ -926,6 +1011,164 @@ static void cycle_start_single(ecat_master_instance_t *inst) } else { atomic_store(&inst->consecutive_wkc_errors, 0); } + return true; +} + +/* 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) +{ + return (uint64_t)ts->tv_sec * 1000000000ULL + (uint64_t)ts->tv_nsec; +} + +/** + * @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. + */ +static void *ecat_bus_thread(void *arg) +{ + ecat_master_instance_t *inst = (ecat_master_instance_t *)arg; + + /* Set thread name for top/htop debugging. */ + char tname[16]; + snprintf(tname, sizeof tname, "ecat-%s", inst->name); + pthread_setname_np(pthread_self(), tname); + + /* Apply SCHED_FIFO at the configured priority. Fall back to the + * default scheduler with a warning rather than refusing to run. */ + int prio = inst->config.master.task_priority; + if (prio < 1) prio = 1; + if (prio > 99) prio = 99; + struct sched_param sp = {0}; + sp.sched_priority = prio; + if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) { + plugin_logger_warn(&g_logger, + "Bus thread '%s': SCHED_FIFO(%d) failed: %s — running with default scheduling", + inst->name, prio, strerror(errno)); + } else { + plugin_logger_info(&g_logger, + "Bus thread '%s': SCHED_FIFO priority %d", inst->name, prio); + } + + /* SIGUSR1 handler is process-wide; installed once at plc_main.c. + * No per-thread sigaction here. SIGUSR1 stays unblocked for this + * thread by default (pthread_create inherits the parent's mask, and + * the process-wide mask doesn't include SIGUSR1). */ + + int64_t interval_ns = + (int64_t)inst->config.master.cycle_time_us * 1000LL; + if (interval_ns <= 0) interval_ns = 1000000LL; /* 1 ms safety floor */ + + /* Seed scheduling-stat min trackers. */ + atomic_store_explicit(&inst->diag.min_period_ns, UINT64_MAX, memory_order_relaxed); + atomic_store_explicit(&inst->diag.min_latency_ns, INT64_MAX, memory_order_relaxed); + + struct timespec next_wakeup; + clock_gettime(CLOCK_MONOTONIC, &next_wakeup); + + bool have_prev_wake = false; + uint64_t prev_wake_ns = 0; + + while (atomic_load(&inst->bus_running)) { + /* Capture actual wake-up time. The first iteration's deadline + * is "now" so latency should be ~0; meaningful from iteration 2. */ + struct timespec actual_wake; + clock_gettime(CLOCK_MONOTONIC, &actual_wake); + uint64_t actual_wake_ns = ts_to_ns(&actual_wake); + uint64_t expected_ns = ts_to_ns(&next_wakeup); + + /* Latency = how much later than the deadline we woke up. Can + * theoretically be slightly negative if clock skew or coarse + * timer granularity puts us a fraction ahead. Subtract in the + * signed domain so an early wake yields a small negative value + * rather than relying on impl-defined unsigned→signed conversion. */ + int64_t latency_ns = (int64_t)actual_wake_ns - (int64_t)expected_ns; + atomic_store_explicit(&inst->diag.latency_ns, latency_ns, memory_order_relaxed); + + int64_t cur_lat_min = atomic_load_explicit(&inst->diag.min_latency_ns, + memory_order_relaxed); + if (latency_ns < cur_lat_min) + atomic_store_explicit(&inst->diag.min_latency_ns, latency_ns, + memory_order_relaxed); + int64_t cur_lat_max = atomic_load_explicit(&inst->diag.max_latency_ns, + memory_order_relaxed); + if (latency_ns > cur_lat_max) + atomic_store_explicit(&inst->diag.max_latency_ns, latency_ns, + memory_order_relaxed); + + /* Time-based EWMA — same scheme as avg_bus_cycle_ns_sum. */ + int64_t cur_lat_sum = atomic_load_explicit(&inst->diag.avg_latency_ns_sum, + memory_order_relaxed); + cur_lat_sum += latency_ns - cur_lat_sum / inst->avg_window; + atomic_store_explicit(&inst->diag.avg_latency_ns_sum, cur_lat_sum, + memory_order_relaxed); + + if (have_prev_wake) { + uint64_t period_ns = actual_wake_ns - prev_wake_ns; + atomic_store_explicit(&inst->diag.period_ns, period_ns, + memory_order_relaxed); + + uint64_t cur_per_min = atomic_load_explicit(&inst->diag.min_period_ns, + memory_order_relaxed); + if (period_ns < cur_per_min) + atomic_store_explicit(&inst->diag.min_period_ns, period_ns, + memory_order_relaxed); + uint64_t cur_per_max = atomic_load_explicit(&inst->diag.max_period_ns, + memory_order_relaxed); + if (period_ns > cur_per_max) + atomic_store_explicit(&inst->diag.max_period_ns, period_ns, + memory_order_relaxed); + + int64_t cur_per_sum = atomic_load_explicit(&inst->diag.avg_period_ns_sum, + memory_order_relaxed); + cur_per_sum += (int64_t)period_ns - cur_per_sum / inst->avg_window; + atomic_store_explicit(&inst->diag.avg_period_ns_sum, cur_per_sum, + memory_order_relaxed); + } + prev_wake_ns = actual_wake_ns; + have_prev_wake = true; + + /* Bus exchange + diag updates happen inside ecat_run_one_cycle. */ + ecat_run_one_cycle(inst); + + next_wakeup.tv_nsec += (long)(interval_ns % 1000000000LL); + next_wakeup.tv_sec += (time_t)(interval_ns / 1000000000LL); + if (next_wakeup.tv_nsec >= 1000000000L) { + next_wakeup.tv_nsec -= 1000000000L; + next_wakeup.tv_sec += 1; + } + int rc = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next_wakeup, NULL); + if (rc == EINTR) continue; /* SIGUSR1 wake — loop will re-check bus_running */ + } + + 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; } /* @@ -1034,23 +1277,10 @@ int init(void *args) } #endif - /* Calculate tick divisor for task-based scheduling. - * A divisor of 1 means "execute every cycle" (the default). */ - inst->tick_divisor = 1; - if (inst->config.master.task_cycle_time_us > 0 && - g_runtime_args.common_ticktime_ns > 0) { - unsigned long long task_ns = - (unsigned long long)inst->config.master.task_cycle_time_us * 1000ULL; - unsigned int divisor = (unsigned int)(task_ns / g_runtime_args.common_ticktime_ns); - if (divisor > 1) - inst->tick_divisor = divisor; - plugin_logger_info(&g_logger, - "Master[%d] '%s': task=%s, task_cycle=%d us, base_tick=%llu ns, divisor=%u", - i, inst->name, inst->config.master.task_name, - inst->config.master.task_cycle_time_us, - (unsigned long long)g_runtime_args.common_ticktime_ns, - inst->tick_divisor); - } + /* 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); @@ -1251,30 +1481,12 @@ void cleanup(void) plugin_logger_info(&g_logger, "EtherCAT plugin cleanup complete"); } -/** - * @brief Called at the start of each PLC scan cycle - * - * Iterates all master instances and performs synchronous process data - * exchange for each one that is in OPERATIONAL or RECOVERING state. - */ -void cycle_start(void) -{ - for (int i = 0; i < g_master_count; i++) { - cycle_start_single(&g_masters[i]); - } -} - -/** - * @brief Called at the end of each PLC scan cycle - * - * Output data is written to the IOmap at the beginning of cycle_start() - * (before the exchange), so this hook is intentionally a no-op. - */ -void cycle_end(void) -{ - /* Outputs are written at the start of the next cycle_start() so they - * are sent in the same SOEM exchange that receives fresh inputs. */ -} +/* 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. */ /* * ============================================================================= @@ -1561,6 +1773,17 @@ typedef struct { 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, @@ -1573,14 +1796,15 @@ static void load_diag_view(const ecat_master_instance_t *inst, out->noframe_count = atomic_load_explicit(&inst->diag.noframe_count, memory_order_relaxed); - /* 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. Both pairs reflect the same exchange (send+receive) - * timing — the duplicated naming is preserved to avoid breaking the - * existing JSON contract. */ - int64_t avg = atomic_load_explicit(&inst->diag.avg_bus_cycle_ns, - memory_order_relaxed); - out->avg_cycle_us = (uint64_t)(avg / 1000); + /* 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); @@ -1593,6 +1817,28 @@ static void load_diag_view(const ecat_master_instance_t *inst, 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; } /** @@ -1664,7 +1910,10 @@ static cJSON *build_master_status_json(ecat_master_instance_t *inst) add_slaves_json(inst, master, &slave_count, false); cJSON_AddNumberToObject(master, "slave_count", slave_count); - /* Cycle metrics */ + /* 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); @@ -1674,6 +1923,12 @@ static cJSON *build_master_status_json(ecat_master_instance_t *inst) 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); @@ -1733,7 +1988,20 @@ static cJSON *build_master_diagnostics_json(ecat_master_instance_t *inst) add_slaves_json(inst, master, &slave_count, true); cJSON_AddNumberToObject(master, "slave_count", slave_count); - /* Extended timing metrics */ + /* 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); @@ -1743,6 +2011,12 @@ static cJSON *build_master_diagnostics_json(ecat_master_instance_t *inst) 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); diff --git a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h index 21faf4ac..9b3ffe4d 100644 --- a/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h +++ b/core/src/drivers/plugins/native/ethercat/ethercat_plugin.h @@ -18,7 +18,7 @@ * 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 common_ticktime. + * the PLC base tick. * * A background monitor thread (enabled by ECAT_ENABLE_MONITOR_THREAD) * handles slave state checking and recovery outside the scan cycle. diff --git a/core/src/drivers/plugins/native/s7comm/CMakeLists.txt b/core/src/drivers/plugins/native/s7comm/CMakeLists.txt index be15fd78..706a705d 100644 --- a/core/src/drivers/plugins/native/s7comm/CMakeLists.txt +++ b/core/src/drivers/plugins/native/s7comm/CMakeLists.txt @@ -43,11 +43,11 @@ set(SNAP7_LIB_SOURCES ) # ============================================================================= -# cJSON Library (embedded for JSON configuration parsing) +# cJSON Library (shared utility for all native plugins) # ============================================================================= set(CJSON_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/cjson/cJSON.c + ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson/cJSON.c ) # ============================================================================= @@ -69,7 +69,7 @@ include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/snap7/core ${CMAKE_CURRENT_SOURCE_DIR}/snap7/sys ${CMAKE_CURRENT_SOURCE_DIR}/snap7/lib - ${CMAKE_CURRENT_SOURCE_DIR}/cjson + ${OPENPLC_ROOT}/core/src/drivers/plugins/native/cjson ${OPENPLC_ROOT}/core/src/drivers ${OPENPLC_ROOT}/core/src/drivers/plugins/native ${OPENPLC_ROOT}/core/src/lib diff --git a/core/src/drivers/plugins/native/s7comm/cjson/cJSON.c b/core/src/drivers/plugins/native/s7comm/cjson/cJSON.c deleted file mode 100644 index 61483d90..00000000 --- a/core/src/drivers/plugins/native/s7comm/cjson/cJSON.c +++ /dev/null @@ -1,3143 +0,0 @@ -/* - Copyright (c) 2009-2017 Dave Gamble and cJSON contributors - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. -*/ - -/* cJSON */ -/* JSON parser in C. */ - -/* disable warnings about old C89 functions in MSVC */ -#if !defined(_CRT_SECURE_NO_DEPRECATE) && defined(_MSC_VER) -#define _CRT_SECURE_NO_DEPRECATE -#endif - -#ifdef __GNUC__ -#pragma GCC visibility push(default) -#endif -#if defined(_MSC_VER) -#pragma warning (push) -/* disable warning about single line comments in system headers */ -#pragma warning (disable : 4001) -#endif - -#include -#include -#include -#include -#include -#include -#include - -#ifdef ENABLE_LOCALES -#include -#endif - -#if defined(_MSC_VER) -#pragma warning (pop) -#endif -#ifdef __GNUC__ -#pragma GCC visibility pop -#endif - -#include "cJSON.h" - -/* define our own boolean type */ -#ifdef true -#undef true -#endif -#define true ((cJSON_bool)1) - -#ifdef false -#undef false -#endif -#define false ((cJSON_bool)0) - -/* define isnan and isinf for ANSI C, if in C99 or above, isnan and isinf has been defined in math.h */ -#ifndef isinf -#define isinf(d) (isnan((d - d)) && !isnan(d)) -#endif -#ifndef isnan -#define isnan(d) (d != d) -#endif - -#ifndef NAN -#ifdef _WIN32 -#define NAN sqrt(-1.0) -#else -#define NAN 0.0/0.0 -#endif -#endif - -typedef struct { - const unsigned char *json; - size_t position; -} error; -static error global_error = { NULL, 0 }; - -CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void) -{ - return (const char*) (global_error.json + global_error.position); -} - -CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item) -{ - if (!cJSON_IsString(item)) - { - return NULL; - } - - return item->valuestring; -} - -CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item) -{ - if (!cJSON_IsNumber(item)) - { - return (double) NAN; - } - - return item->valuedouble; -} - -/* This is a safeguard to prevent copy-pasters from using incompatible C and header files */ -#if (CJSON_VERSION_MAJOR != 1) || (CJSON_VERSION_MINOR != 7) || (CJSON_VERSION_PATCH != 18) - #error cJSON.h and cJSON.c have different versions. Make sure that both have the same. -#endif - -CJSON_PUBLIC(const char*) cJSON_Version(void) -{ - static char version[15]; - sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH); - - return version; -} - -/* Case insensitive string comparison, doesn't consider two NULL pointers equal though */ -static int case_insensitive_strcmp(const unsigned char *string1, const unsigned char *string2) -{ - if ((string1 == NULL) || (string2 == NULL)) - { - return 1; - } - - if (string1 == string2) - { - return 0; - } - - for(; tolower(*string1) == tolower(*string2); (void)string1++, string2++) - { - if (*string1 == '\0') - { - return 0; - } - } - - return tolower(*string1) - tolower(*string2); -} - -typedef struct internal_hooks -{ - void *(CJSON_CDECL *allocate)(size_t size); - void (CJSON_CDECL *deallocate)(void *pointer); - void *(CJSON_CDECL *reallocate)(void *pointer, size_t size); -} internal_hooks; - -#if defined(_MSC_VER) -/* work around MSVC error C2322: '...' address of dllimport '...' is not static */ -static void * CJSON_CDECL internal_malloc(size_t size) -{ - return malloc(size); -} -static void CJSON_CDECL internal_free(void *pointer) -{ - free(pointer); -} -static void * CJSON_CDECL internal_realloc(void *pointer, size_t size) -{ - return realloc(pointer, size); -} -#else -#define internal_malloc malloc -#define internal_free free -#define internal_realloc realloc -#endif - -/* strlen of character literals resolved at compile time */ -#define static_strlen(string_literal) (sizeof(string_literal) - sizeof("")) - -static internal_hooks global_hooks = { internal_malloc, internal_free, internal_realloc }; - -static unsigned char* cJSON_strdup(const unsigned char* string, const internal_hooks * const hooks) -{ - size_t length = 0; - unsigned char *copy = NULL; - - if (string == NULL) - { - return NULL; - } - - length = strlen((const char*)string) + sizeof(""); - copy = (unsigned char*)hooks->allocate(length); - if (copy == NULL) - { - return NULL; - } - memcpy(copy, string, length); - - return copy; -} - -CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks) -{ - if (hooks == NULL) - { - /* Reset hooks */ - global_hooks.allocate = malloc; - global_hooks.deallocate = free; - global_hooks.reallocate = realloc; - return; - } - - global_hooks.allocate = malloc; - if (hooks->malloc_fn != NULL) - { - global_hooks.allocate = hooks->malloc_fn; - } - - global_hooks.deallocate = free; - if (hooks->free_fn != NULL) - { - global_hooks.deallocate = hooks->free_fn; - } - - /* use realloc only if both free and malloc are used */ - global_hooks.reallocate = NULL; - if ((global_hooks.allocate == malloc) && (global_hooks.deallocate == free)) - { - global_hooks.reallocate = realloc; - } -} - -/* Internal constructor. */ -static cJSON *cJSON_New_Item(const internal_hooks * const hooks) -{ - cJSON* node = (cJSON*)hooks->allocate(sizeof(cJSON)); - if (node) - { - memset(node, '\0', sizeof(cJSON)); - } - - return node; -} - -/* Delete a cJSON structure. */ -CJSON_PUBLIC(void) cJSON_Delete(cJSON *item) -{ - cJSON *next = NULL; - while (item != NULL) - { - next = item->next; - if (!(item->type & cJSON_IsReference) && (item->child != NULL)) - { - cJSON_Delete(item->child); - } - if (!(item->type & cJSON_IsReference) && (item->valuestring != NULL)) - { - global_hooks.deallocate(item->valuestring); - item->valuestring = NULL; - } - if (!(item->type & cJSON_StringIsConst) && (item->string != NULL)) - { - global_hooks.deallocate(item->string); - item->string = NULL; - } - global_hooks.deallocate(item); - item = next; - } -} - -/* get the decimal point character of the current locale */ -static unsigned char get_decimal_point(void) -{ -#ifdef ENABLE_LOCALES - struct lconv *lconv = localeconv(); - return (unsigned char) lconv->decimal_point[0]; -#else - return '.'; -#endif -} - -typedef struct -{ - const unsigned char *content; - size_t length; - size_t offset; - size_t depth; /* How deeply nested (in arrays/objects) is the input at the current offset. */ - internal_hooks hooks; -} parse_buffer; - -/* check if the given size is left to read in a given parse buffer (starting with 1) */ -#define can_read(buffer, size) ((buffer != NULL) && (((buffer)->offset + size) <= (buffer)->length)) -/* check if the buffer can be accessed at the given index (starting with 0) */ -#define can_access_at_index(buffer, index) ((buffer != NULL) && (((buffer)->offset + index) < (buffer)->length)) -#define cannot_access_at_index(buffer, index) (!can_access_at_index(buffer, index)) -/* get a pointer to the buffer at the position */ -#define buffer_at_offset(buffer) ((buffer)->content + (buffer)->offset) - -/* Parse the input text to generate a number, and populate the result into item. */ -static cJSON_bool parse_number(cJSON * const item, parse_buffer * const input_buffer) -{ - double number = 0; - unsigned char *after_end = NULL; - unsigned char number_c_string[64]; - unsigned char decimal_point = get_decimal_point(); - size_t i = 0; - - if ((input_buffer == NULL) || (input_buffer->content == NULL)) - { - return false; - } - - /* copy the number into a temporary buffer and replace '.' with the decimal point - * of the current locale (for strtod) - * This also takes care of '\0' not necessarily being available for marking the end of the input */ - for (i = 0; (i < (sizeof(number_c_string) - 1)) && can_access_at_index(input_buffer, i); i++) - { - switch (buffer_at_offset(input_buffer)[i]) - { - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case '+': - case '-': - case 'e': - case 'E': - number_c_string[i] = buffer_at_offset(input_buffer)[i]; - break; - - case '.': - number_c_string[i] = decimal_point; - break; - - default: - goto loop_end; - } - } -loop_end: - number_c_string[i] = '\0'; - - number = strtod((const char*)number_c_string, (char**)&after_end); - if (number_c_string == after_end) - { - return false; /* parse_error */ - } - - item->valuedouble = number; - - /* use saturation in case of overflow */ - if (number >= INT_MAX) - { - item->valueint = INT_MAX; - } - else if (number <= (double)INT_MIN) - { - item->valueint = INT_MIN; - } - else - { - item->valueint = (int)number; - } - - item->type = cJSON_Number; - - input_buffer->offset += (size_t)(after_end - number_c_string); - return true; -} - -/* don't ask me, but the original cJSON_SetNumberValue returns an integer or double */ -CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number) -{ - if (number >= INT_MAX) - { - object->valueint = INT_MAX; - } - else if (number <= (double)INT_MIN) - { - object->valueint = INT_MIN; - } - else - { - object->valueint = (int)number; - } - - return object->valuedouble = number; -} - -/* Note: when passing a NULL valuestring, cJSON_SetValuestring treats this as an error and return NULL */ -CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring) -{ - char *copy = NULL; - /* if object's type is not cJSON_String or is cJSON_IsReference, it should not set valuestring */ - if ((object == NULL) || !(object->type & cJSON_String) || (object->type & cJSON_IsReference)) - { - return NULL; - } - /* return NULL if the object is corrupted or valuestring is NULL */ - if (object->valuestring == NULL || valuestring == NULL) - { - return NULL; - } - if (strlen(valuestring) <= strlen(object->valuestring)) - { - strcpy(object->valuestring, valuestring); - return object->valuestring; - } - copy = (char*) cJSON_strdup((const unsigned char*)valuestring, &global_hooks); - if (copy == NULL) - { - return NULL; - } - if (object->valuestring != NULL) - { - cJSON_free(object->valuestring); - } - object->valuestring = copy; - - return copy; -} - -typedef struct -{ - unsigned char *buffer; - size_t length; - size_t offset; - size_t depth; /* current nesting depth (for formatted printing) */ - cJSON_bool noalloc; - cJSON_bool format; /* is this print a formatted print */ - internal_hooks hooks; -} printbuffer; - -/* realloc printbuffer if necessary to have at least "needed" bytes more */ -static unsigned char* ensure(printbuffer * const p, size_t needed) -{ - unsigned char *newbuffer = NULL; - size_t newsize = 0; - - if ((p == NULL) || (p->buffer == NULL)) - { - return NULL; - } - - if ((p->length > 0) && (p->offset >= p->length)) - { - /* make sure that offset is valid */ - return NULL; - } - - if (needed > INT_MAX) - { - /* sizes bigger than INT_MAX are currently not supported */ - return NULL; - } - - needed += p->offset + 1; - if (needed <= p->length) - { - return p->buffer + p->offset; - } - - if (p->noalloc) { - return NULL; - } - - /* calculate new buffer size */ - if (needed > (INT_MAX / 2)) - { - /* overflow of int, use INT_MAX if possible */ - if (needed <= INT_MAX) - { - newsize = INT_MAX; - } - else - { - return NULL; - } - } - else - { - newsize = needed * 2; - } - - if (p->hooks.reallocate != NULL) - { - /* reallocate with realloc if available */ - newbuffer = (unsigned char*)p->hooks.reallocate(p->buffer, newsize); - if (newbuffer == NULL) - { - p->hooks.deallocate(p->buffer); - p->length = 0; - p->buffer = NULL; - - return NULL; - } - } - else - { - /* otherwise reallocate manually */ - newbuffer = (unsigned char*)p->hooks.allocate(newsize); - if (!newbuffer) - { - p->hooks.deallocate(p->buffer); - p->length = 0; - p->buffer = NULL; - - return NULL; - } - - memcpy(newbuffer, p->buffer, p->offset + 1); - p->hooks.deallocate(p->buffer); - } - p->length = newsize; - p->buffer = newbuffer; - - return newbuffer + p->offset; -} - -/* calculate the new length of the string in a printbuffer and update the offset */ -static void update_offset(printbuffer * const buffer) -{ - const unsigned char *buffer_pointer = NULL; - if ((buffer == NULL) || (buffer->buffer == NULL)) - { - return; - } - buffer_pointer = buffer->buffer + buffer->offset; - - buffer->offset += strlen((const char*)buffer_pointer); -} - -/* securely comparison of floating-point variables */ -static cJSON_bool compare_double(double a, double b) -{ - double maxVal = fabs(a) > fabs(b) ? fabs(a) : fabs(b); - return (fabs(a - b) <= maxVal * DBL_EPSILON); -} - -/* Render the number nicely from the given item into a string. */ -static cJSON_bool print_number(const cJSON * const item, printbuffer * const output_buffer) -{ - unsigned char *output_pointer = NULL; - double d = item->valuedouble; - int length = 0; - size_t i = 0; - unsigned char number_buffer[26] = {0}; /* temporary buffer to print the number into */ - unsigned char decimal_point = get_decimal_point(); - double test = 0.0; - - if (output_buffer == NULL) - { - return false; - } - - /* This checks for NaN and Infinity */ - if (isnan(d) || isinf(d)) - { - length = sprintf((char*)number_buffer, "null"); - } - else if(d == (double)item->valueint) - { - length = sprintf((char*)number_buffer, "%d", item->valueint); - } - else - { - /* Try 15 decimal places of precision to avoid nonsignificant nonzero digits */ - length = sprintf((char*)number_buffer, "%1.15g", d); - - /* Check whether the original double can be recovered */ - if ((sscanf((char*)number_buffer, "%lg", &test) != 1) || !compare_double((double)test, d)) - { - /* If not, print with 17 decimal places of precision */ - length = sprintf((char*)number_buffer, "%1.17g", d); - } - } - - /* sprintf failed or buffer overrun occurred */ - if ((length < 0) || (length > (int)(sizeof(number_buffer) - 1))) - { - return false; - } - - /* reserve appropriate space in the output */ - output_pointer = ensure(output_buffer, (size_t)length + sizeof("")); - if (output_pointer == NULL) - { - return false; - } - - /* copy the printed number to the output and replace locale - * dependent decimal point with '.' */ - for (i = 0; i < ((size_t)length); i++) - { - if (number_buffer[i] == decimal_point) - { - output_pointer[i] = '.'; - continue; - } - - output_pointer[i] = number_buffer[i]; - } - output_pointer[i] = '\0'; - - output_buffer->offset += (size_t)length; - - return true; -} - -/* parse 4 digit hexadecimal number */ -static unsigned parse_hex4(const unsigned char * const input) -{ - unsigned int h = 0; - size_t i = 0; - - for (i = 0; i < 4; i++) - { - /* parse digit */ - if ((input[i] >= '0') && (input[i] <= '9')) - { - h += (unsigned int) input[i] - '0'; - } - else if ((input[i] >= 'A') && (input[i] <= 'F')) - { - h += (unsigned int) 10 + input[i] - 'A'; - } - else if ((input[i] >= 'a') && (input[i] <= 'f')) - { - h += (unsigned int) 10 + input[i] - 'a'; - } - else /* invalid */ - { - return 0; - } - - if (i < 3) - { - /* shift left to make place for the next nibble */ - h = h << 4; - } - } - - return h; -} - -/* converts a UTF-16 literal to UTF-8 - * A literal can be one or two sequences of the form \uXXXX */ -static unsigned char utf16_literal_to_utf8(const unsigned char * const input_pointer, const unsigned char * const input_end, unsigned char **output_pointer) -{ - long unsigned int codepoint = 0; - unsigned int first_code = 0; - const unsigned char *first_sequence = input_pointer; - unsigned char utf8_length = 0; - unsigned char utf8_position = 0; - unsigned char sequence_length = 0; - unsigned char first_byte_mark = 0; - - if ((input_end - first_sequence) < 6) - { - /* input ends unexpectedly */ - goto fail; - } - - /* get the first utf16 sequence */ - first_code = parse_hex4(first_sequence + 2); - - /* check that the code is valid */ - if (((first_code >= 0xDC00) && (first_code <= 0xDFFF))) - { - goto fail; - } - - /* UTF16 surrogate pair */ - if ((first_code >= 0xD800) && (first_code <= 0xDBFF)) - { - const unsigned char *second_sequence = first_sequence + 6; - unsigned int second_code = 0; - sequence_length = 12; /* \uXXXX\uXXXX */ - - if ((input_end - second_sequence) < 6) - { - /* input ends unexpectedly */ - goto fail; - } - - if ((second_sequence[0] != '\\') || (second_sequence[1] != 'u')) - { - /* missing second half of the surrogate pair */ - goto fail; - } - - /* get the second utf16 sequence */ - second_code = parse_hex4(second_sequence + 2); - /* check that the code is valid */ - if ((second_code < 0xDC00) || (second_code > 0xDFFF)) - { - /* invalid second half of the surrogate pair */ - goto fail; - } - - - /* calculate the unicode codepoint from the surrogate pair */ - codepoint = 0x10000 + (((first_code & 0x3FF) << 10) | (second_code & 0x3FF)); - } - else - { - sequence_length = 6; /* \uXXXX */ - codepoint = first_code; - } - - /* encode as UTF-8 - * takes at maximum 4 bytes to encode: - * 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx */ - if (codepoint < 0x80) - { - /* normal ascii, encoding 0xxxxxxx */ - utf8_length = 1; - } - else if (codepoint < 0x800) - { - /* two bytes, encoding 110xxxxx 10xxxxxx */ - utf8_length = 2; - first_byte_mark = 0xC0; /* 11000000 */ - } - else if (codepoint < 0x10000) - { - /* three bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx */ - utf8_length = 3; - first_byte_mark = 0xE0; /* 11100000 */ - } - else if (codepoint <= 0x10FFFF) - { - /* four bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx */ - utf8_length = 4; - first_byte_mark = 0xF0; /* 11110000 */ - } - else - { - /* invalid unicode codepoint */ - goto fail; - } - - /* encode as utf8 */ - for (utf8_position = (unsigned char)(utf8_length - 1); utf8_position > 0; utf8_position--) - { - /* 10xxxxxx */ - (*output_pointer)[utf8_position] = (unsigned char)((codepoint | 0x80) & 0xBF); - codepoint >>= 6; - } - /* encode first byte */ - if (utf8_length > 1) - { - (*output_pointer)[0] = (unsigned char)((codepoint | first_byte_mark) & 0xFF); - } - else - { - (*output_pointer)[0] = (unsigned char)(codepoint & 0x7F); - } - - *output_pointer += utf8_length; - - return sequence_length; - -fail: - return 0; -} - -/* Parse the input text into an unescaped cinput, and populate item. */ -static cJSON_bool parse_string(cJSON * const item, parse_buffer * const input_buffer) -{ - const unsigned char *input_pointer = buffer_at_offset(input_buffer) + 1; - const unsigned char *input_end = buffer_at_offset(input_buffer) + 1; - unsigned char *output_pointer = NULL; - unsigned char *output = NULL; - - /* not a string */ - if (buffer_at_offset(input_buffer)[0] != '\"') - { - goto fail; - } - - { - /* calculate approximate size of the output (overestimate) */ - size_t allocation_length = 0; - size_t skipped_bytes = 0; - while (((size_t)(input_end - input_buffer->content) < input_buffer->length) && (*input_end != '\"')) - { - /* is escape sequence */ - if (input_end[0] == '\\') - { - if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length) - { - /* prevent buffer overflow when last input character is a backslash */ - goto fail; - } - skipped_bytes++; - input_end++; - } - input_end++; - } - if (((size_t)(input_end - input_buffer->content) >= input_buffer->length) || (*input_end != '\"')) - { - goto fail; /* string ended unexpectedly */ - } - - /* This is at most how much we need for the output */ - allocation_length = (size_t) (input_end - buffer_at_offset(input_buffer)) - skipped_bytes; - output = (unsigned char*)input_buffer->hooks.allocate(allocation_length + sizeof("")); - if (output == NULL) - { - goto fail; /* allocation failure */ - } - } - - output_pointer = output; - /* loop through the string literal */ - while (input_pointer < input_end) - { - if (*input_pointer != '\\') - { - *output_pointer++ = *input_pointer++; - } - /* escape sequence */ - else - { - unsigned char sequence_length = 2; - if ((input_end - input_pointer) < 1) - { - goto fail; - } - - switch (input_pointer[1]) - { - case 'b': - *output_pointer++ = '\b'; - break; - case 'f': - *output_pointer++ = '\f'; - break; - case 'n': - *output_pointer++ = '\n'; - break; - case 'r': - *output_pointer++ = '\r'; - break; - case 't': - *output_pointer++ = '\t'; - break; - case '\"': - case '\\': - case '/': - *output_pointer++ = input_pointer[1]; - break; - - /* UTF-16 literal */ - case 'u': - sequence_length = utf16_literal_to_utf8(input_pointer, input_end, &output_pointer); - if (sequence_length == 0) - { - /* failed to convert UTF16-literal to UTF-8 */ - goto fail; - } - break; - - default: - goto fail; - } - input_pointer += sequence_length; - } - } - - /* zero terminate the output */ - *output_pointer = '\0'; - - item->type = cJSON_String; - item->valuestring = (char*)output; - - input_buffer->offset = (size_t) (input_end - input_buffer->content); - input_buffer->offset++; - - return true; - -fail: - if (output != NULL) - { - input_buffer->hooks.deallocate(output); - output = NULL; - } - - if (input_pointer != NULL) - { - input_buffer->offset = (size_t)(input_pointer - input_buffer->content); - } - - return false; -} - -/* Render the cstring provided to an escaped version that can be printed. */ -static cJSON_bool print_string_ptr(const unsigned char * const input, printbuffer * const output_buffer) -{ - const unsigned char *input_pointer = NULL; - unsigned char *output = NULL; - unsigned char *output_pointer = NULL; - size_t output_length = 0; - /* numbers of additional characters needed for escaping */ - size_t escape_characters = 0; - - if (output_buffer == NULL) - { - return false; - } - - /* empty string */ - if (input == NULL) - { - output = ensure(output_buffer, sizeof("\"\"")); - if (output == NULL) - { - return false; - } - strcpy((char*)output, "\"\""); - - return true; - } - - /* set "flag" to 1 if something needs to be escaped */ - for (input_pointer = input; *input_pointer; input_pointer++) - { - switch (*input_pointer) - { - case '\"': - case '\\': - case '\b': - case '\f': - case '\n': - case '\r': - case '\t': - /* one character escape sequence */ - escape_characters++; - break; - default: - if (*input_pointer < 32) - { - /* UTF-16 escape sequence uXXXX */ - escape_characters += 5; - } - break; - } - } - output_length = (size_t)(input_pointer - input) + escape_characters; - - output = ensure(output_buffer, output_length + sizeof("\"\"")); - if (output == NULL) - { - return false; - } - - /* no characters have to be escaped */ - if (escape_characters == 0) - { - output[0] = '\"'; - memcpy(output + 1, input, output_length); - output[output_length + 1] = '\"'; - output[output_length + 2] = '\0'; - - return true; - } - - output[0] = '\"'; - output_pointer = output + 1; - /* copy the string */ - for (input_pointer = input; *input_pointer != '\0'; (void)input_pointer++, output_pointer++) - { - if ((*input_pointer > 31) && (*input_pointer != '\"') && (*input_pointer != '\\')) - { - /* normal character, copy */ - *output_pointer = *input_pointer; - } - else - { - /* character needs to be escaped */ - *output_pointer++ = '\\'; - switch (*input_pointer) - { - case '\\': - *output_pointer = '\\'; - break; - case '\"': - *output_pointer = '\"'; - break; - case '\b': - *output_pointer = 'b'; - break; - case '\f': - *output_pointer = 'f'; - break; - case '\n': - *output_pointer = 'n'; - break; - case '\r': - *output_pointer = 'r'; - break; - case '\t': - *output_pointer = 't'; - break; - default: - /* escape and print as unicode codepoint */ - sprintf((char*)output_pointer, "u%04x", *input_pointer); - output_pointer += 4; - break; - } - } - } - output[output_length + 1] = '\"'; - output[output_length + 2] = '\0'; - - return true; -} - -/* Invoke print_string_ptr (which is useful) on an item. */ -static cJSON_bool print_string(const cJSON * const item, printbuffer * const p) -{ - return print_string_ptr((unsigned char*)item->valuestring, p); -} - -/* Predeclare these prototypes. */ -static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer); -static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer); -static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer); -static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer); -static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer); -static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer); - -/* Utility to jump whitespace and cr/lf */ -static parse_buffer *buffer_skip_whitespace(parse_buffer * const buffer) -{ - if ((buffer == NULL) || (buffer->content == NULL)) - { - return NULL; - } - - if (cannot_access_at_index(buffer, 0)) - { - return buffer; - } - - while (can_access_at_index(buffer, 0) && (buffer_at_offset(buffer)[0] <= 32)) - { - buffer->offset++; - } - - if (buffer->offset == buffer->length) - { - buffer->offset--; - } - - return buffer; -} - -/* skip the UTF-8 BOM (byte order mark) if it is at the beginning of a buffer */ -static parse_buffer *skip_utf8_bom(parse_buffer * const buffer) -{ - if ((buffer == NULL) || (buffer->content == NULL) || (buffer->offset != 0)) - { - return NULL; - } - - if (can_access_at_index(buffer, 4) && (strncmp((const char*)buffer_at_offset(buffer), "\xEF\xBB\xBF", 3) == 0)) - { - buffer->offset += 3; - } - - return buffer; -} - -CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated) -{ - size_t buffer_length; - - if (NULL == value) - { - return NULL; - } - - /* Adding null character size due to require_null_terminated. */ - buffer_length = strlen(value) + sizeof(""); - - return cJSON_ParseWithLengthOpts(value, buffer_length, return_parse_end, require_null_terminated); -} - -/* Parse an object - create a new root, and populate. */ -CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated) -{ - parse_buffer buffer = { 0, 0, 0, 0, { 0, 0, 0 } }; - cJSON *item = NULL; - - /* reset error position */ - global_error.json = NULL; - global_error.position = 0; - - if (value == NULL || 0 == buffer_length) - { - goto fail; - } - - buffer.content = (const unsigned char*)value; - buffer.length = buffer_length; - buffer.offset = 0; - buffer.hooks = global_hooks; - - item = cJSON_New_Item(&global_hooks); - if (item == NULL) /* memory fail */ - { - goto fail; - } - - if (!parse_value(item, buffer_skip_whitespace(skip_utf8_bom(&buffer)))) - { - /* parse failure. ep is set. */ - goto fail; - } - - /* if we require null-terminated JSON without appended garbage, skip and then check for a null terminator */ - if (require_null_terminated) - { - buffer_skip_whitespace(&buffer); - if ((buffer.offset >= buffer.length) || buffer_at_offset(&buffer)[0] != '\0') - { - goto fail; - } - } - if (return_parse_end) - { - *return_parse_end = (const char*)buffer_at_offset(&buffer); - } - - return item; - -fail: - if (item != NULL) - { - cJSON_Delete(item); - } - - if (value != NULL) - { - error local_error; - local_error.json = (const unsigned char*)value; - local_error.position = 0; - - if (buffer.offset < buffer.length) - { - local_error.position = buffer.offset; - } - else if (buffer.length > 0) - { - local_error.position = buffer.length - 1; - } - - if (return_parse_end != NULL) - { - *return_parse_end = (const char*)local_error.json + local_error.position; - } - - global_error = local_error; - } - - return NULL; -} - -/* Default options for cJSON_Parse */ -CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value) -{ - return cJSON_ParseWithOpts(value, 0, 0); -} - -CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length) -{ - return cJSON_ParseWithLengthOpts(value, buffer_length, 0, 0); -} - -#define cjson_min(a, b) (((a) < (b)) ? (a) : (b)) - -static unsigned char *print(const cJSON * const item, cJSON_bool format, const internal_hooks * const hooks) -{ - static const size_t default_buffer_size = 256; - printbuffer buffer[1]; - unsigned char *printed = NULL; - - memset(buffer, 0, sizeof(buffer)); - - /* create buffer */ - buffer->buffer = (unsigned char*) hooks->allocate(default_buffer_size); - buffer->length = default_buffer_size; - buffer->format = format; - buffer->hooks = *hooks; - if (buffer->buffer == NULL) - { - goto fail; - } - - /* print the value */ - if (!print_value(item, buffer)) - { - goto fail; - } - update_offset(buffer); - - /* check if reallocate is available */ - if (hooks->reallocate != NULL) - { - printed = (unsigned char*) hooks->reallocate(buffer->buffer, buffer->offset + 1); - if (printed == NULL) { - goto fail; - } - buffer->buffer = NULL; - } - else /* otherwise copy the JSON over to a new buffer */ - { - printed = (unsigned char*) hooks->allocate(buffer->offset + 1); - if (printed == NULL) - { - goto fail; - } - memcpy(printed, buffer->buffer, cjson_min(buffer->length, buffer->offset + 1)); - printed[buffer->offset] = '\0'; /* just to be sure */ - - /* free the buffer */ - hooks->deallocate(buffer->buffer); - buffer->buffer = NULL; - } - - return printed; - -fail: - if (buffer->buffer != NULL) - { - hooks->deallocate(buffer->buffer); - buffer->buffer = NULL; - } - - if (printed != NULL) - { - hooks->deallocate(printed); - printed = NULL; - } - - return NULL; -} - -/* Render a cJSON item/entity/structure to text. */ -CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item) -{ - return (char*)print(item, true, &global_hooks); -} - -CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item) -{ - return (char*)print(item, false, &global_hooks); -} - -CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt) -{ - printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } }; - - if (prebuffer < 0) - { - return NULL; - } - - p.buffer = (unsigned char*)global_hooks.allocate((size_t)prebuffer); - if (!p.buffer) - { - return NULL; - } - - p.length = (size_t)prebuffer; - p.offset = 0; - p.noalloc = false; - p.format = fmt; - p.hooks = global_hooks; - - if (!print_value(item, &p)) - { - global_hooks.deallocate(p.buffer); - p.buffer = NULL; - return NULL; - } - - return (char*)p.buffer; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format) -{ - printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } }; - - if ((length < 0) || (buffer == NULL)) - { - return false; - } - - p.buffer = (unsigned char*)buffer; - p.length = (size_t)length; - p.offset = 0; - p.noalloc = true; - p.format = format; - p.hooks = global_hooks; - - return print_value(item, &p); -} - -/* Parser core - when encountering text, process appropriately. */ -static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer) -{ - if ((input_buffer == NULL) || (input_buffer->content == NULL)) - { - return false; /* no input */ - } - - /* parse the different types of values */ - /* null */ - if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "null", 4) == 0)) - { - item->type = cJSON_NULL; - input_buffer->offset += 4; - return true; - } - /* false */ - if (can_read(input_buffer, 5) && (strncmp((const char*)buffer_at_offset(input_buffer), "false", 5) == 0)) - { - item->type = cJSON_False; - input_buffer->offset += 5; - return true; - } - /* true */ - if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "true", 4) == 0)) - { - item->type = cJSON_True; - item->valueint = 1; - input_buffer->offset += 4; - return true; - } - /* string */ - if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '\"')) - { - return parse_string(item, input_buffer); - } - /* number */ - if (can_access_at_index(input_buffer, 0) && ((buffer_at_offset(input_buffer)[0] == '-') || ((buffer_at_offset(input_buffer)[0] >= '0') && (buffer_at_offset(input_buffer)[0] <= '9')))) - { - return parse_number(item, input_buffer); - } - /* array */ - if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '[')) - { - return parse_array(item, input_buffer); - } - /* object */ - if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '{')) - { - return parse_object(item, input_buffer); - } - - return false; -} - -/* Render a value to text. */ -static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer) -{ - unsigned char *output = NULL; - - if ((item == NULL) || (output_buffer == NULL)) - { - return false; - } - - switch ((item->type) & 0xFF) - { - case cJSON_NULL: - output = ensure(output_buffer, 5); - if (output == NULL) - { - return false; - } - strcpy((char*)output, "null"); - return true; - - case cJSON_False: - output = ensure(output_buffer, 6); - if (output == NULL) - { - return false; - } - strcpy((char*)output, "false"); - return true; - - case cJSON_True: - output = ensure(output_buffer, 5); - if (output == NULL) - { - return false; - } - strcpy((char*)output, "true"); - return true; - - case cJSON_Number: - return print_number(item, output_buffer); - - case cJSON_Raw: - { - size_t raw_length = 0; - if (item->valuestring == NULL) - { - return false; - } - - raw_length = strlen(item->valuestring) + sizeof(""); - output = ensure(output_buffer, raw_length); - if (output == NULL) - { - return false; - } - memcpy(output, item->valuestring, raw_length); - return true; - } - - case cJSON_String: - return print_string(item, output_buffer); - - case cJSON_Array: - return print_array(item, output_buffer); - - case cJSON_Object: - return print_object(item, output_buffer); - - default: - return false; - } -} - -/* Build an array from input text. */ -static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer) -{ - cJSON *head = NULL; /* head of the linked list */ - cJSON *current_item = NULL; - - if (input_buffer->depth >= CJSON_NESTING_LIMIT) - { - return false; /* to deeply nested */ - } - input_buffer->depth++; - - if (buffer_at_offset(input_buffer)[0] != '[') - { - /* not an array */ - goto fail; - } - - input_buffer->offset++; - buffer_skip_whitespace(input_buffer); - if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ']')) - { - /* empty array */ - goto success; - } - - /* check if we skipped to the end of the buffer */ - if (cannot_access_at_index(input_buffer, 0)) - { - input_buffer->offset--; - goto fail; - } - - /* step back to character in front of the first element */ - input_buffer->offset--; - /* loop through the comma separated array elements */ - do - { - /* allocate next item */ - cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks)); - if (new_item == NULL) - { - goto fail; /* allocation failure */ - } - - /* attach next item to list */ - if (head == NULL) - { - /* start the linked list */ - current_item = head = new_item; - } - else - { - /* add to the end and advance */ - current_item->next = new_item; - new_item->prev = current_item; - current_item = new_item; - } - - /* parse next value */ - input_buffer->offset++; - buffer_skip_whitespace(input_buffer); - if (!parse_value(current_item, input_buffer)) - { - goto fail; /* failed to parse value */ - } - buffer_skip_whitespace(input_buffer); - } - while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ',')); - - if (cannot_access_at_index(input_buffer, 0) || buffer_at_offset(input_buffer)[0] != ']') - { - goto fail; /* expected end of array */ - } - -success: - input_buffer->depth--; - - if (head != NULL) { - head->prev = current_item; - } - - item->type = cJSON_Array; - item->child = head; - - input_buffer->offset++; - - return true; - -fail: - if (head != NULL) - { - cJSON_Delete(head); - } - - return false; -} - -/* Render an array to text */ -static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer) -{ - unsigned char *output_pointer = NULL; - size_t length = 0; - cJSON *current_element = item->child; - - if (output_buffer == NULL) - { - return false; - } - - /* Compose the output array. */ - /* opening square bracket */ - output_pointer = ensure(output_buffer, 1); - if (output_pointer == NULL) - { - return false; - } - - *output_pointer = '['; - output_buffer->offset++; - output_buffer->depth++; - - while (current_element != NULL) - { - if (!print_value(current_element, output_buffer)) - { - return false; - } - update_offset(output_buffer); - if (current_element->next) - { - length = (size_t) (output_buffer->format ? 2 : 1); - output_pointer = ensure(output_buffer, length + 1); - if (output_pointer == NULL) - { - return false; - } - *output_pointer++ = ','; - if(output_buffer->format) - { - *output_pointer++ = ' '; - } - *output_pointer = '\0'; - output_buffer->offset += length; - } - current_element = current_element->next; - } - - output_pointer = ensure(output_buffer, 2); - if (output_pointer == NULL) - { - return false; - } - *output_pointer++ = ']'; - *output_pointer = '\0'; - output_buffer->depth--; - - return true; -} - -/* Build an object from the text. */ -static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer) -{ - cJSON *head = NULL; /* linked list head */ - cJSON *current_item = NULL; - - if (input_buffer->depth >= CJSON_NESTING_LIMIT) - { - return false; /* to deeply nested */ - } - input_buffer->depth++; - - if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '{')) - { - goto fail; /* not an object */ - } - - input_buffer->offset++; - buffer_skip_whitespace(input_buffer); - if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '}')) - { - goto success; /* empty object */ - } - - /* check if we skipped to the end of the buffer */ - if (cannot_access_at_index(input_buffer, 0)) - { - input_buffer->offset--; - goto fail; - } - - /* step back to character in front of the first element */ - input_buffer->offset--; - /* loop through the comma separated array elements */ - do - { - /* allocate next item */ - cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks)); - if (new_item == NULL) - { - goto fail; /* allocation failure */ - } - - /* attach next item to list */ - if (head == NULL) - { - /* start the linked list */ - current_item = head = new_item; - } - else - { - /* add to the end and advance */ - current_item->next = new_item; - new_item->prev = current_item; - current_item = new_item; - } - - if (cannot_access_at_index(input_buffer, 1)) - { - goto fail; /* nothing comes after the comma */ - } - - /* parse the name of the child */ - input_buffer->offset++; - buffer_skip_whitespace(input_buffer); - if (!parse_string(current_item, input_buffer)) - { - goto fail; /* failed to parse name */ - } - buffer_skip_whitespace(input_buffer); - - /* swap valuestring and string, because we parsed the name */ - current_item->string = current_item->valuestring; - current_item->valuestring = NULL; - - if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != ':')) - { - goto fail; /* invalid object */ - } - - /* parse the value */ - input_buffer->offset++; - buffer_skip_whitespace(input_buffer); - if (!parse_value(current_item, input_buffer)) - { - goto fail; /* failed to parse value */ - } - buffer_skip_whitespace(input_buffer); - } - while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ',')); - - if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '}')) - { - goto fail; /* expected end of object */ - } - -success: - input_buffer->depth--; - - if (head != NULL) { - head->prev = current_item; - } - - item->type = cJSON_Object; - item->child = head; - - input_buffer->offset++; - return true; - -fail: - if (head != NULL) - { - cJSON_Delete(head); - } - - return false; -} - -/* Render an object to text. */ -static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer) -{ - unsigned char *output_pointer = NULL; - size_t length = 0; - cJSON *current_item = item->child; - - if (output_buffer == NULL) - { - return false; - } - - /* Compose the output: */ - length = (size_t) (output_buffer->format ? 2 : 1); /* fmt: {\n */ - output_pointer = ensure(output_buffer, length + 1); - if (output_pointer == NULL) - { - return false; - } - - *output_pointer++ = '{'; - output_buffer->depth++; - if (output_buffer->format) - { - *output_pointer++ = '\n'; - } - output_buffer->offset += length; - - while (current_item) - { - if (output_buffer->format) - { - size_t i; - output_pointer = ensure(output_buffer, output_buffer->depth); - if (output_pointer == NULL) - { - return false; - } - for (i = 0; i < output_buffer->depth; i++) - { - *output_pointer++ = '\t'; - } - output_buffer->offset += output_buffer->depth; - } - - /* print key */ - if (!print_string_ptr((unsigned char*)current_item->string, output_buffer)) - { - return false; - } - update_offset(output_buffer); - - length = (size_t) (output_buffer->format ? 2 : 1); - output_pointer = ensure(output_buffer, length); - if (output_pointer == NULL) - { - return false; - } - *output_pointer++ = ':'; - if (output_buffer->format) - { - *output_pointer++ = '\t'; - } - output_buffer->offset += length; - - /* print value */ - if (!print_value(current_item, output_buffer)) - { - return false; - } - update_offset(output_buffer); - - /* print comma if not last */ - length = ((size_t)(output_buffer->format ? 1 : 0) + (size_t)(current_item->next ? 1 : 0)); - output_pointer = ensure(output_buffer, length + 1); - if (output_pointer == NULL) - { - return false; - } - if (current_item->next) - { - *output_pointer++ = ','; - } - - if (output_buffer->format) - { - *output_pointer++ = '\n'; - } - *output_pointer = '\0'; - output_buffer->offset += length; - - current_item = current_item->next; - } - - output_pointer = ensure(output_buffer, output_buffer->format ? (output_buffer->depth + 1) : 2); - if (output_pointer == NULL) - { - return false; - } - if (output_buffer->format) - { - size_t i; - for (i = 0; i < (output_buffer->depth - 1); i++) - { - *output_pointer++ = '\t'; - } - } - *output_pointer++ = '}'; - *output_pointer = '\0'; - output_buffer->depth--; - - return true; -} - -/* Get Array size/item / object item. */ -CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array) -{ - cJSON *child = NULL; - size_t size = 0; - - if (array == NULL) - { - return 0; - } - - child = array->child; - - while(child != NULL) - { - size++; - child = child->next; - } - - /* FIXME: Can overflow here. Cannot be fixed without breaking the API */ - - return (int)size; -} - -static cJSON* get_array_item(const cJSON *array, size_t index) -{ - cJSON *current_child = NULL; - - if (array == NULL) - { - return NULL; - } - - current_child = array->child; - while ((current_child != NULL) && (index > 0)) - { - index--; - current_child = current_child->next; - } - - return current_child; -} - -CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index) -{ - if (index < 0) - { - return NULL; - } - - return get_array_item(array, (size_t)index); -} - -static cJSON *get_object_item(const cJSON * const object, const char * const name, const cJSON_bool case_sensitive) -{ - cJSON *current_element = NULL; - - if ((object == NULL) || (name == NULL)) - { - return NULL; - } - - current_element = object->child; - if (case_sensitive) - { - while ((current_element != NULL) && (current_element->string != NULL) && (strcmp(name, current_element->string) != 0)) - { - current_element = current_element->next; - } - } - else - { - while ((current_element != NULL) && (case_insensitive_strcmp((const unsigned char*)name, (const unsigned char*)(current_element->string)) != 0)) - { - current_element = current_element->next; - } - } - - if ((current_element == NULL) || (current_element->string == NULL)) { - return NULL; - } - - return current_element; -} - -CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string) -{ - return get_object_item(object, string, false); -} - -CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string) -{ - return get_object_item(object, string, true); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string) -{ - return cJSON_GetObjectItem(object, string) ? 1 : 0; -} - -/* Utility for array list handling. */ -static void suffix_object(cJSON *prev, cJSON *item) -{ - prev->next = item; - item->prev = prev; -} - -/* Utility for handling references. */ -static cJSON *create_reference(const cJSON *item, const internal_hooks * const hooks) -{ - cJSON *reference = NULL; - if (item == NULL) - { - return NULL; - } - - reference = cJSON_New_Item(hooks); - if (reference == NULL) - { - return NULL; - } - - memcpy(reference, item, sizeof(cJSON)); - reference->string = NULL; - reference->type |= cJSON_IsReference; - reference->next = reference->prev = NULL; - return reference; -} - -static cJSON_bool add_item_to_array(cJSON *array, cJSON *item) -{ - cJSON *child = NULL; - - if ((item == NULL) || (array == NULL) || (array == item)) - { - return false; - } - - child = array->child; - /* - * To find the last item in array quickly, we use prev in array - */ - if (child == NULL) - { - /* list is empty, start new one */ - array->child = item; - item->prev = item; - item->next = NULL; - } - else - { - /* append to the end */ - if (child->prev) - { - suffix_object(child->prev, item); - array->child->prev = item; - } - } - - return true; -} - -/* Add item to array/object. */ -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item) -{ - return add_item_to_array(array, item); -} - -#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5)))) - #pragma GCC diagnostic push -#endif -#ifdef __GNUC__ -#pragma GCC diagnostic ignored "-Wcast-qual" -#endif -/* helper function to cast away const */ -static void* cast_away_const(const void* string) -{ - return (void*)string; -} -#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5)))) - #pragma GCC diagnostic pop -#endif - - -static cJSON_bool add_item_to_object(cJSON * const object, const char * const string, cJSON * const item, const internal_hooks * const hooks, const cJSON_bool constant_key) -{ - char *new_key = NULL; - int new_type = cJSON_Invalid; - - if ((object == NULL) || (string == NULL) || (item == NULL) || (object == item)) - { - return false; - } - - if (constant_key) - { - new_key = (char*)cast_away_const(string); - new_type = item->type | cJSON_StringIsConst; - } - else - { - new_key = (char*)cJSON_strdup((const unsigned char*)string, hooks); - if (new_key == NULL) - { - return false; - } - - new_type = item->type & ~cJSON_StringIsConst; - } - - if (!(item->type & cJSON_StringIsConst) && (item->string != NULL)) - { - hooks->deallocate(item->string); - } - - item->string = new_key; - item->type = new_type; - - return add_item_to_array(object, item); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item) -{ - return add_item_to_object(object, string, item, &global_hooks, false); -} - -/* Add an item to an object with constant string as key */ -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item) -{ - return add_item_to_object(object, string, item, &global_hooks, true); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item) -{ - if (array == NULL) - { - return false; - } - - return add_item_to_array(array, create_reference(item, &global_hooks)); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item) -{ - if ((object == NULL) || (string == NULL)) - { - return false; - } - - return add_item_to_object(object, string, create_reference(item, &global_hooks), &global_hooks, false); -} - -CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name) -{ - cJSON *null = cJSON_CreateNull(); - if (add_item_to_object(object, name, null, &global_hooks, false)) - { - return null; - } - - cJSON_Delete(null); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name) -{ - cJSON *true_item = cJSON_CreateTrue(); - if (add_item_to_object(object, name, true_item, &global_hooks, false)) - { - return true_item; - } - - cJSON_Delete(true_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name) -{ - cJSON *false_item = cJSON_CreateFalse(); - if (add_item_to_object(object, name, false_item, &global_hooks, false)) - { - return false_item; - } - - cJSON_Delete(false_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean) -{ - cJSON *bool_item = cJSON_CreateBool(boolean); - if (add_item_to_object(object, name, bool_item, &global_hooks, false)) - { - return bool_item; - } - - cJSON_Delete(bool_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number) -{ - cJSON *number_item = cJSON_CreateNumber(number); - if (add_item_to_object(object, name, number_item, &global_hooks, false)) - { - return number_item; - } - - cJSON_Delete(number_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string) -{ - cJSON *string_item = cJSON_CreateString(string); - if (add_item_to_object(object, name, string_item, &global_hooks, false)) - { - return string_item; - } - - cJSON_Delete(string_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw) -{ - cJSON *raw_item = cJSON_CreateRaw(raw); - if (add_item_to_object(object, name, raw_item, &global_hooks, false)) - { - return raw_item; - } - - cJSON_Delete(raw_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name) -{ - cJSON *object_item = cJSON_CreateObject(); - if (add_item_to_object(object, name, object_item, &global_hooks, false)) - { - return object_item; - } - - cJSON_Delete(object_item); - return NULL; -} - -CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name) -{ - cJSON *array = cJSON_CreateArray(); - if (add_item_to_object(object, name, array, &global_hooks, false)) - { - return array; - } - - cJSON_Delete(array); - return NULL; -} - -CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item) -{ - if ((parent == NULL) || (item == NULL)) - { - return NULL; - } - - if (item != parent->child) - { - /* not the first element */ - item->prev->next = item->next; - } - if (item->next != NULL) - { - /* not the last element */ - item->next->prev = item->prev; - } - - if (item == parent->child) - { - /* first element */ - parent->child = item->next; - } - else if (item->next == NULL) - { - /* last element */ - parent->child->prev = item->prev; - } - - /* make sure the detached item doesn't point anywhere anymore */ - item->prev = NULL; - item->next = NULL; - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which) -{ - if (which < 0) - { - return NULL; - } - - return cJSON_DetachItemViaPointer(array, get_array_item(array, (size_t)which)); -} - -CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which) -{ - cJSON_Delete(cJSON_DetachItemFromArray(array, which)); -} - -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string) -{ - cJSON *to_detach = cJSON_GetObjectItem(object, string); - - return cJSON_DetachItemViaPointer(object, to_detach); -} - -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string) -{ - cJSON *to_detach = cJSON_GetObjectItemCaseSensitive(object, string); - - return cJSON_DetachItemViaPointer(object, to_detach); -} - -CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string) -{ - cJSON_Delete(cJSON_DetachItemFromObject(object, string)); -} - -CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string) -{ - cJSON_Delete(cJSON_DetachItemFromObjectCaseSensitive(object, string)); -} - -/* Replace array/object items with new ones. */ -CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem) -{ - cJSON *after_inserted = NULL; - - if (which < 0 || newitem == NULL) - { - return false; - } - - after_inserted = get_array_item(array, (size_t)which); - if (after_inserted == NULL) - { - return add_item_to_array(array, newitem); - } - - if (after_inserted != array->child && after_inserted->prev == NULL) { - /* return false if after_inserted is a corrupted array item */ - return false; - } - - newitem->next = after_inserted; - newitem->prev = after_inserted->prev; - after_inserted->prev = newitem; - if (after_inserted == array->child) - { - array->child = newitem; - } - else - { - newitem->prev->next = newitem; - } - return true; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement) -{ - if ((parent == NULL) || (parent->child == NULL) || (replacement == NULL) || (item == NULL)) - { - return false; - } - - if (replacement == item) - { - return true; - } - - replacement->next = item->next; - replacement->prev = item->prev; - - if (replacement->next != NULL) - { - replacement->next->prev = replacement; - } - if (parent->child == item) - { - if (parent->child->prev == parent->child) - { - replacement->prev = replacement; - } - parent->child = replacement; - } - else - { /* - * To find the last item in array quickly, we use prev in array. - * We can't modify the last item's next pointer where this item was the parent's child - */ - if (replacement->prev != NULL) - { - replacement->prev->next = replacement; - } - if (replacement->next == NULL) - { - parent->child->prev = replacement; - } - } - - item->next = NULL; - item->prev = NULL; - cJSON_Delete(item); - - return true; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem) -{ - if (which < 0) - { - return false; - } - - return cJSON_ReplaceItemViaPointer(array, get_array_item(array, (size_t)which), newitem); -} - -static cJSON_bool replace_item_in_object(cJSON *object, const char *string, cJSON *replacement, cJSON_bool case_sensitive) -{ - if ((replacement == NULL) || (string == NULL)) - { - return false; - } - - /* replace the name in the replacement */ - if (!(replacement->type & cJSON_StringIsConst) && (replacement->string != NULL)) - { - cJSON_free(replacement->string); - } - replacement->string = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks); - if (replacement->string == NULL) - { - return false; - } - - replacement->type &= ~cJSON_StringIsConst; - - return cJSON_ReplaceItemViaPointer(object, get_object_item(object, string, case_sensitive), replacement); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object, const char *string, cJSON *newitem) -{ - return replace_item_in_object(object, string, newitem, false); -} - -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object, const char *string, cJSON *newitem) -{ - return replace_item_in_object(object, string, newitem, true); -} - -/* Create basic types: */ -CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_NULL; - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_True; - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_False; - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = boolean ? cJSON_True : cJSON_False; - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_Number; - item->valuedouble = num; - - /* use saturation in case of overflow */ - if (num >= INT_MAX) - { - item->valueint = INT_MAX; - } - else if (num <= (double)INT_MIN) - { - item->valueint = INT_MIN; - } - else - { - item->valueint = (int)num; - } - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_String; - item->valuestring = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks); - if(!item->valuestring) - { - cJSON_Delete(item); - return NULL; - } - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if (item != NULL) - { - item->type = cJSON_String | cJSON_IsReference; - item->valuestring = (char*)cast_away_const(string); - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if (item != NULL) { - item->type = cJSON_Object | cJSON_IsReference; - item->child = (cJSON*)cast_away_const(child); - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child) { - cJSON *item = cJSON_New_Item(&global_hooks); - if (item != NULL) { - item->type = cJSON_Array | cJSON_IsReference; - item->child = (cJSON*)cast_away_const(child); - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type = cJSON_Raw; - item->valuestring = (char*)cJSON_strdup((const unsigned char*)raw, &global_hooks); - if(!item->valuestring) - { - cJSON_Delete(item); - return NULL; - } - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if(item) - { - item->type=cJSON_Array; - } - - return item; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void) -{ - cJSON *item = cJSON_New_Item(&global_hooks); - if (item) - { - item->type = cJSON_Object; - } - - return item; -} - -/* Create Arrays: */ -CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count) -{ - size_t i = 0; - cJSON *n = NULL; - cJSON *p = NULL; - cJSON *a = NULL; - - if ((count < 0) || (numbers == NULL)) - { - return NULL; - } - - a = cJSON_CreateArray(); - - for(i = 0; a && (i < (size_t)count); i++) - { - n = cJSON_CreateNumber(numbers[i]); - if (!n) - { - cJSON_Delete(a); - return NULL; - } - if(!i) - { - a->child = n; - } - else - { - suffix_object(p, n); - } - p = n; - } - - if (a && a->child) { - a->child->prev = n; - } - - return a; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count) -{ - size_t i = 0; - cJSON *n = NULL; - cJSON *p = NULL; - cJSON *a = NULL; - - if ((count < 0) || (numbers == NULL)) - { - return NULL; - } - - a = cJSON_CreateArray(); - - for(i = 0; a && (i < (size_t)count); i++) - { - n = cJSON_CreateNumber((double)numbers[i]); - if(!n) - { - cJSON_Delete(a); - return NULL; - } - if(!i) - { - a->child = n; - } - else - { - suffix_object(p, n); - } - p = n; - } - - if (a && a->child) { - a->child->prev = n; - } - - return a; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count) -{ - size_t i = 0; - cJSON *n = NULL; - cJSON *p = NULL; - cJSON *a = NULL; - - if ((count < 0) || (numbers == NULL)) - { - return NULL; - } - - a = cJSON_CreateArray(); - - for(i = 0; a && (i < (size_t)count); i++) - { - n = cJSON_CreateNumber(numbers[i]); - if(!n) - { - cJSON_Delete(a); - return NULL; - } - if(!i) - { - a->child = n; - } - else - { - suffix_object(p, n); - } - p = n; - } - - if (a && a->child) { - a->child->prev = n; - } - - return a; -} - -CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count) -{ - size_t i = 0; - cJSON *n = NULL; - cJSON *p = NULL; - cJSON *a = NULL; - - if ((count < 0) || (strings == NULL)) - { - return NULL; - } - - a = cJSON_CreateArray(); - - for (i = 0; a && (i < (size_t)count); i++) - { - n = cJSON_CreateString(strings[i]); - if(!n) - { - cJSON_Delete(a); - return NULL; - } - if(!i) - { - a->child = n; - } - else - { - suffix_object(p,n); - } - p = n; - } - - if (a && a->child) { - a->child->prev = n; - } - - return a; -} - -/* Duplication */ -CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse) -{ - cJSON *newitem = NULL; - cJSON *child = NULL; - cJSON *next = NULL; - cJSON *newchild = NULL; - - /* Bail on bad ptr */ - if (!item) - { - goto fail; - } - /* Create new item */ - newitem = cJSON_New_Item(&global_hooks); - if (!newitem) - { - goto fail; - } - /* Copy over all vars */ - newitem->type = item->type & (~cJSON_IsReference); - newitem->valueint = item->valueint; - newitem->valuedouble = item->valuedouble; - if (item->valuestring) - { - newitem->valuestring = (char*)cJSON_strdup((unsigned char*)item->valuestring, &global_hooks); - if (!newitem->valuestring) - { - goto fail; - } - } - if (item->string) - { - newitem->string = (item->type&cJSON_StringIsConst) ? item->string : (char*)cJSON_strdup((unsigned char*)item->string, &global_hooks); - if (!newitem->string) - { - goto fail; - } - } - /* If non-recursive, then we're done! */ - if (!recurse) - { - return newitem; - } - /* Walk the ->next chain for the child. */ - child = item->child; - while (child != NULL) - { - newchild = cJSON_Duplicate(child, true); /* Duplicate (with recurse) each item in the ->next chain */ - if (!newchild) - { - goto fail; - } - if (next != NULL) - { - /* If newitem->child already set, then crosswire ->prev and ->next and move on */ - next->next = newchild; - newchild->prev = next; - next = newchild; - } - else - { - /* Set newitem->child and move to it */ - newitem->child = newchild; - next = newchild; - } - child = child->next; - } - if (newitem && newitem->child) - { - newitem->child->prev = newchild; - } - - return newitem; - -fail: - if (newitem != NULL) - { - cJSON_Delete(newitem); - } - - return NULL; -} - -static void skip_oneline_comment(char **input) -{ - *input += static_strlen("//"); - - for (; (*input)[0] != '\0'; ++(*input)) - { - if ((*input)[0] == '\n') { - *input += static_strlen("\n"); - return; - } - } -} - -static void skip_multiline_comment(char **input) -{ - *input += static_strlen("/*"); - - for (; (*input)[0] != '\0'; ++(*input)) - { - if (((*input)[0] == '*') && ((*input)[1] == '/')) - { - *input += static_strlen("*/"); - return; - } - } -} - -static void minify_string(char **input, char **output) { - (*output)[0] = (*input)[0]; - *input += static_strlen("\""); - *output += static_strlen("\""); - - - for (; (*input)[0] != '\0'; (void)++(*input), ++(*output)) { - (*output)[0] = (*input)[0]; - - if ((*input)[0] == '\"') { - (*output)[0] = '\"'; - *input += static_strlen("\""); - *output += static_strlen("\""); - return; - } else if (((*input)[0] == '\\') && ((*input)[1] == '\"')) { - (*output)[1] = (*input)[1]; - *input += static_strlen("\""); - *output += static_strlen("\""); - } - } -} - -CJSON_PUBLIC(void) cJSON_Minify(char *json) -{ - char *into = json; - - if (json == NULL) - { - return; - } - - while (json[0] != '\0') - { - switch (json[0]) - { - case ' ': - case '\t': - case '\r': - case '\n': - json++; - break; - - case '/': - if (json[1] == '/') - { - skip_oneline_comment(&json); - } - else if (json[1] == '*') - { - skip_multiline_comment(&json); - } else { - json++; - } - break; - - case '\"': - minify_string(&json, (char**)&into); - break; - - default: - into[0] = json[0]; - json++; - into++; - } - } - - /* and null-terminate. */ - *into = '\0'; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_Invalid; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_False; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xff) == cJSON_True; -} - - -CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & (cJSON_True | cJSON_False)) != 0; -} -CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_NULL; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_Number; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_String; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_Array; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_Object; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item) -{ - if (item == NULL) - { - return false; - } - - return (item->type & 0xFF) == cJSON_Raw; -} - -CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive) -{ - if ((a == NULL) || (b == NULL) || ((a->type & 0xFF) != (b->type & 0xFF))) - { - return false; - } - - /* check if type is valid */ - switch (a->type & 0xFF) - { - case cJSON_False: - case cJSON_True: - case cJSON_NULL: - case cJSON_Number: - case cJSON_String: - case cJSON_Raw: - case cJSON_Array: - case cJSON_Object: - break; - - default: - return false; - } - - /* identical objects are equal */ - if (a == b) - { - return true; - } - - switch (a->type & 0xFF) - { - /* in these cases and equal type is enough */ - case cJSON_False: - case cJSON_True: - case cJSON_NULL: - return true; - - case cJSON_Number: - if (compare_double(a->valuedouble, b->valuedouble)) - { - return true; - } - return false; - - case cJSON_String: - case cJSON_Raw: - if ((a->valuestring == NULL) || (b->valuestring == NULL)) - { - return false; - } - if (strcmp(a->valuestring, b->valuestring) == 0) - { - return true; - } - - return false; - - case cJSON_Array: - { - cJSON *a_element = a->child; - cJSON *b_element = b->child; - - for (; (a_element != NULL) && (b_element != NULL);) - { - if (!cJSON_Compare(a_element, b_element, case_sensitive)) - { - return false; - } - - a_element = a_element->next; - b_element = b_element->next; - } - - /* one of the arrays is longer than the other */ - if (a_element != b_element) { - return false; - } - - return true; - } - - case cJSON_Object: - { - cJSON *a_element = NULL; - cJSON *b_element = NULL; - cJSON_ArrayForEach(a_element, a) - { - /* TODO This has O(n^2) runtime, which is horrible! */ - b_element = get_object_item(b, a_element->string, case_sensitive); - if (b_element == NULL) - { - return false; - } - - if (!cJSON_Compare(a_element, b_element, case_sensitive)) - { - return false; - } - } - - /* doing this twice, once on a and b to prevent true comparison if a subset of b - * TODO: Do this the proper way, this is just a fix for now */ - cJSON_ArrayForEach(b_element, b) - { - a_element = get_object_item(a, b_element->string, case_sensitive); - if (a_element == NULL) - { - return false; - } - - if (!cJSON_Compare(b_element, a_element, case_sensitive)) - { - return false; - } - } - - return true; - } - - default: - return false; - } -} - -CJSON_PUBLIC(void *) cJSON_malloc(size_t size) -{ - return global_hooks.allocate(size); -} - -CJSON_PUBLIC(void) cJSON_free(void *object) -{ - global_hooks.deallocate(object); - object = NULL; -} diff --git a/core/src/drivers/plugins/native/s7comm/cjson/cJSON.h b/core/src/drivers/plugins/native/s7comm/cjson/cJSON.h deleted file mode 100644 index 88cf0bcf..00000000 --- a/core/src/drivers/plugins/native/s7comm/cjson/cJSON.h +++ /dev/null @@ -1,300 +0,0 @@ -/* - Copyright (c) 2009-2017 Dave Gamble and cJSON contributors - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. -*/ - -#ifndef cJSON__h -#define cJSON__h - -#ifdef __cplusplus -extern "C" -{ -#endif - -#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32)) -#define __WINDOWS__ -#endif - -#ifdef __WINDOWS__ - -/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options: - -CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols -CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default) -CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol - -For *nix builds that support visibility attribute, you can define similar behavior by - -setting default visibility to hidden by adding --fvisibility=hidden (for gcc) -or --xldscope=hidden (for sun cc) -to CFLAGS - -then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does - -*/ - -#define CJSON_CDECL __cdecl -#define CJSON_STDCALL __stdcall - -/* export symbols by default, this is necessary for copy pasting the C and header file */ -#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS) -#define CJSON_EXPORT_SYMBOLS -#endif - -#if defined(CJSON_HIDE_SYMBOLS) -#define CJSON_PUBLIC(type) type CJSON_STDCALL -#elif defined(CJSON_EXPORT_SYMBOLS) -#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL -#elif defined(CJSON_IMPORT_SYMBOLS) -#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL -#endif -#else /* !__WINDOWS__ */ -#define CJSON_CDECL -#define CJSON_STDCALL - -#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY) -#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type -#else -#define CJSON_PUBLIC(type) type -#endif -#endif - -/* project version */ -#define CJSON_VERSION_MAJOR 1 -#define CJSON_VERSION_MINOR 7 -#define CJSON_VERSION_PATCH 18 - -#include - -/* cJSON Types: */ -#define cJSON_Invalid (0) -#define cJSON_False (1 << 0) -#define cJSON_True (1 << 1) -#define cJSON_NULL (1 << 2) -#define cJSON_Number (1 << 3) -#define cJSON_String (1 << 4) -#define cJSON_Array (1 << 5) -#define cJSON_Object (1 << 6) -#define cJSON_Raw (1 << 7) /* raw json */ - -#define cJSON_IsReference 256 -#define cJSON_StringIsConst 512 - -/* The cJSON structure: */ -typedef struct cJSON -{ - /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */ - struct cJSON *next; - struct cJSON *prev; - /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */ - struct cJSON *child; - - /* The type of the item, as above. */ - int type; - - /* The item's string, if type==cJSON_String and type == cJSON_Raw */ - char *valuestring; - /* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */ - int valueint; - /* The item's number, if type==cJSON_Number */ - double valuedouble; - - /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */ - char *string; -} cJSON; - -typedef struct cJSON_Hooks -{ - /* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */ - void *(CJSON_CDECL *malloc_fn)(size_t sz); - void (CJSON_CDECL *free_fn)(void *ptr); -} cJSON_Hooks; - -typedef int cJSON_bool; - -/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them. - * This is to prevent stack overflows. */ -#ifndef CJSON_NESTING_LIMIT -#define CJSON_NESTING_LIMIT 1000 -#endif - -/* returns the version of cJSON as a string */ -CJSON_PUBLIC(const char*) cJSON_Version(void); - -/* Supply malloc, realloc and free functions to cJSON */ -CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks); - -/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */ -/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */ -CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value); -CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length); -/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */ -/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */ -CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated); -CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated); - -/* Render a cJSON entity to text for transfer/storage. */ -CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item); -/* Render a cJSON entity to text for transfer/storage without any formatting. */ -CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item); -/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */ -CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt); -/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */ -/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */ -CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format); -/* Delete a cJSON entity and all subentities. */ -CJSON_PUBLIC(void) cJSON_Delete(cJSON *item); - -/* Returns the number of items in an array (or object). */ -CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array); -/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */ -CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index); -/* Get item "string" from object. Case insensitive. */ -CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string); -CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string); -CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string); -/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */ -CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void); - -/* Check item type and return its value */ -CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item); -CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item); - -/* These functions check the type of an item */ -CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item); -CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item); - -/* These calls create a cJSON item of the appropriate type. */ -CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void); -CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void); -CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void); -CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean); -CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num); -CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string); -/* raw json */ -CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw); -CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void); -CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void); - -/* Create a string where valuestring references a string so - * it will not be freed by cJSON_Delete */ -CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string); -/* Create an object/array that only references it's elements so - * they will not be freed by cJSON_Delete */ -CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child); -CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child); - -/* These utilities create an Array of count items. - * The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/ -CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count); -CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count); -CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count); -CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count); - -/* Append item to the specified array/object. */ -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item); -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item); -/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object. - * WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before - * writing to `item->string` */ -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item); -/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */ -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item); -CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item); - -/* Remove/Detach items from Arrays/Objects. */ -CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item); -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which); -CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which); -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string); -CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string); -CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string); -CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string); - -/* Update array items. */ -CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */ -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement); -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem); -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem); -CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem); - -/* Duplicate a cJSON item */ -CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse); -/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will - * need to be released. With recurse!=0, it will duplicate any children connected to the item. - * The item->next and ->prev pointers are always zero on return from Duplicate. */ -/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal. - * case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */ -CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive); - -/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings. - * The input pointer json cannot point to a read-only address area, such as a string constant, - * but should point to a readable and writable address area. */ -CJSON_PUBLIC(void) cJSON_Minify(char *json); - -/* Helper functions for creating and adding items to an object at the same time. - * They return the added item or NULL on failure. */ -CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name); -CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name); -CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name); -CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean); -CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number); -CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string); -CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw); -CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name); -CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name); - -/* When assigning an integer value, it needs to be propagated to valuedouble too. */ -#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number)) -/* helper for the cJSON_SetNumberValue macro */ -CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number); -#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number)) -/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */ -CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring); - -/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/ -#define cJSON_SetBoolValue(object, boolValue) ( \ - (object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \ - (object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \ - cJSON_Invalid\ -) - -/* Macro for iterating over an array or object */ -#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next) - -/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */ -CJSON_PUBLIC(void *) cJSON_malloc(size_t size); -CJSON_PUBLIC(void) cJSON_free(void *object); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/core/src/drivers/plugins/python/opcua/address_space.py b/core/src/drivers/plugins/python/opcua/address_space.py index c174ca0f..fa6ab853 100644 --- a/core/src/drivers/plugins/python/opcua/address_space.py +++ b/core/src/drivers/plugins/python/opcua/address_space.py @@ -8,7 +8,7 @@ import os import sys import traceback -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Tuple from asyncua import Server, ua from asyncua.common.node import Node @@ -41,6 +41,21 @@ ) +def _type_default(datatype: str) -> Any: + """Per-type seed value for newly-created OPC-UA nodes. Replaces + the removed `initial_value` config field — the first sync cycle + overwrites this with the program's actual current value via + debug_read, so the seed only shows for one polling tick.""" + t = (datatype or "").upper() + if t == "BOOL": + return False + if t in ("REAL", "LREAL"): + return 0.0 + if t in ("STRING", "WSTRING"): + return "" + return 0 + + class AddressSpaceBuilder: """ Builds OPC-UA address space from configuration. @@ -51,7 +66,7 @@ class AddressSpaceBuilder: - Array variable nodes After building, provides mappings for: - - variable_nodes: Dict[int, VariableNode] - index to node mapping + - variable_nodes: Dict[(arr, elem), VariableNode] - address → node mapping - node_permissions: Dict[str, VariablePermissions] - node_id to permissions - nodeid_to_variable: Dict[Any, str] - NodeId to variable name mapping """ @@ -74,8 +89,10 @@ def __init__( self.namespace_idx = namespace_idx self.config = config - # Output mappings (populated during build) - self.variable_nodes: Dict[int, VariableNode] = {} + # Output mappings (populated during build). Keyed by + # (arr, elem) tuple — the same address space the runtime's + # debug_read / debug_write / debug_set thunks consume. + self.variable_nodes: Dict[Tuple[int, int], VariableNode] = {} self.node_permissions: Dict[str, VariablePermissions] = {} self.nodeid_to_variable: Dict[Any, str] = {} @@ -135,7 +152,10 @@ async def _create_simple_variable( var: SimpleVariable configuration """ opcua_type = map_plc_to_opcua_type(var.datatype) - initial_value = convert_value_for_opcua(var.datatype, var.initial_value) + # Type-defaulted seed value. The first sync cycle reads the + # program's actual current value via debug_read and updates + # the node — no `initial_value` config field anymore. + initial_value = convert_value_for_opcua(var.datatype, _type_default(var.datatype)) # Create the variable node node = await parent_node.add_variable( @@ -173,17 +193,18 @@ async def _create_simple_variable( access_mode = "readwrite" if has_write_permission else "readonly" var_node = VariableNode( node=node, - debug_var_index=var.index, + arr=var.arr, + elem=var.elem, datatype=var.datatype, access_mode=access_mode, is_array_element=False ) - self.variable_nodes[var.index] = var_node + self.variable_nodes[(var.arr, var.elem)] = var_node self.node_permissions[var.node_id] = var.permissions self.nodeid_to_variable[node.nodeid] = var.node_id - log_debug(f"Created variable {var.node_id} (index: {var.index})") + log_debug(f"Created variable {var.node_id} (arr={var.arr}, elem={var.elem})") async def _create_struct( self, @@ -271,7 +292,7 @@ async def _create_struct_field( # This is a leaf field - create a Variable node opcua_type = map_plc_to_opcua_type(field.datatype) - initial_value = convert_value_for_opcua(field.datatype, field.initial_value) + initial_value = convert_value_for_opcua(field.datatype, _type_default(field.datatype)) # Create the variable node node = await parent_node.add_variable( @@ -295,25 +316,27 @@ async def _create_struct_field( if has_write_permission: await node.set_writable() - # Store node mapping (only for leaf fields with valid indices) - if field.index is not None: + # Store node mapping (only for leaf fields with valid addresses). + if field.arr is not None and field.elem is not None: access_mode = "readwrite" if has_write_permission else "readonly" var_node = VariableNode( node=node, - debug_var_index=field.index, + arr=field.arr, + elem=field.elem, datatype=field.datatype, access_mode=access_mode, is_array_element=False ) - self.variable_nodes[field.index] = var_node + self.variable_nodes[(field.arr, field.elem)] = var_node self.node_permissions[field_node_id] = field.permissions self.nodeid_to_variable[node.nodeid] = field_node_id - log_debug(f"Created field {field_node_id} (index: {field.index})") + log_debug(f"Created field {field_node_id} (arr={field.arr}, elem={field.elem})") else: - # Complex types (FBs, nested structs) have null indices - only leaf fields have indices - log_debug(f"Field {field_node_id} is a complex type (no index) - skipping node mapping") + # Complex types (FBs, nested structs) have null addresses; + # only their leaf children carry (arr, elem). + log_debug(f"Field {field_node_id} is a complex type (no address) - skipping node mapping") async def _create_array( self, @@ -328,7 +351,7 @@ async def _create_array( arr: ArrayVariable configuration """ opcua_type = map_plc_to_opcua_type(arr.datatype) - initial_value = convert_value_for_opcua(arr.datatype, arr.initial_value) + initial_value = convert_value_for_opcua(arr.datatype, _type_default(arr.datatype)) # Create array with initial values array_values = [initial_value] * arr.length @@ -372,18 +395,24 @@ async def _create_array( access_mode = "readwrite" if has_write_permission else "readonly" var_node = VariableNode( node=node, - debug_var_index=arr.index, + arr=arr.arr, + elem=arr.elem, datatype=arr.datatype, access_mode=access_mode, is_array_element=False, array_length=arr.length ) - self.variable_nodes[arr.index] = var_node + # Key the array by its base (arr, elem) — sync loops iterate + # length consecutive (arr, elem+i) addresses. + self.variable_nodes[(arr.arr, arr.elem)] = var_node self.node_permissions[arr.node_id] = arr.permissions self.nodeid_to_variable[node.nodeid] = arr.node_id - log_debug(f"Created array {arr.node_id}[{arr.length}] (index: {arr.index})") + log_debug( + f"Created array {arr.node_id}[{arr.length}] " + f"(arr={arr.arr}, elem={arr.elem})" + ) def _check_write_permission(self, permissions: VariablePermissions) -> bool: """ diff --git a/core/src/drivers/plugins/python/opcua/opcua_config_template.json b/core/src/drivers/plugins/python/opcua/opcua_config_template.json index 65a610a0..0166e6ec 100644 --- a/core/src/drivers/plugins/python/opcua/opcua_config_template.json +++ b/core/src/drivers/plugins/python/opcua/opcua_config_template.json @@ -85,9 +85,9 @@ "browse_name": "bool_var", "display_name": "Boolean Variable", "datatype": "BOOL", - "initial_value": false, "description": "Example boolean variable", - "index": 0, + "arr": 0, + "elem": 0, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -95,9 +95,9 @@ "browse_name": "int_var", "display_name": "Integer Variable", "datatype": "INT", - "initial_value": 0, "description": "Example 16-bit signed integer", - "index": 1, + "arr": 0, + "elem": 1, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -105,9 +105,9 @@ "browse_name": "dint_var", "display_name": "Double Integer Variable", "datatype": "DINT", - "initial_value": 0, "description": "Example 32-bit signed integer", - "index": 2, + "arr": 0, + "elem": 2, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -115,9 +115,9 @@ "browse_name": "real_var", "display_name": "Real Variable", "datatype": "REAL", - "initial_value": 0.0, "description": "Example 32-bit floating point", - "index": 3, + "arr": 0, + "elem": 3, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -125,9 +125,9 @@ "browse_name": "string_var", "display_name": "String Variable", "datatype": "STRING", - "initial_value": "", "description": "Example string variable", - "index": 4, + "arr": 0, + "elem": 4, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -135,9 +135,9 @@ "browse_name": "readonly_counter", "display_name": "Read-Only Counter", "datatype": "DINT", - "initial_value": 0, "description": "Example read-only variable", - "index": 5, + "arr": 0, + "elem": 5, "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} }, { @@ -145,9 +145,9 @@ "browse_name": "cycle_time", "display_name": "Cycle Time", "datatype": "TIME", - "initial_value": 0, "description": "PLC scan cycle time (TIME type, represented as milliseconds in OPC-UA)", - "index": 6, + "arr": 0, + "elem": 6, "permissions": {"viewer": "r", "operator": "r", "engineer": "rw"} }, { @@ -155,9 +155,9 @@ "browse_name": "timer_preset", "display_name": "Timer Preset", "datatype": "TIME", - "initial_value": 0, "description": "Timer preset value (TIME type)", - "index": 7, + "arr": 0, + "elem": 7, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -165,9 +165,9 @@ "browse_name": "time_of_day", "display_name": "Time of Day", "datatype": "TOD", - "initial_value": 0, "description": "Current time of day (TOD type, mapped to OPC-UA DateTime)", - "index": 8, + "arr": 0, + "elem": 8, "permissions": {"viewer": "r", "operator": "r", "engineer": "rw"} } ], @@ -181,22 +181,22 @@ { "name": "sensor_id", "datatype": "INT", - "initial_value": 1, - "index": 10, + "arr": 0, + "elem": 10, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { "name": "value", "datatype": "REAL", - "initial_value": 0.0, - "index": 11, + "arr": 0, + "elem": 11, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { "name": "is_valid", "datatype": "BOOL", - "initial_value": false, - "index": 12, + "arr": 0, + "elem": 12, "permissions": {"viewer": "r", "operator": "r", "engineer": "r"} } ] @@ -209,8 +209,8 @@ "display_name": "Integer Array", "datatype": "INT", "length": 5, - "initial_value": 0, - "index": 20, + "arr": 0, + "elem": 20, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} }, { @@ -219,8 +219,8 @@ "display_name": "Real Array", "datatype": "REAL", "length": 4, - "initial_value": 0.0, - "index": 25, + "arr": 0, + "elem": 25, "permissions": {"viewer": "r", "operator": "rw", "engineer": "rw"} } ] diff --git a/core/src/drivers/plugins/python/opcua/opcua_memory.py b/core/src/drivers/plugins/python/opcua/opcua_memory.py index 08b488d1..4946ae6b 100644 --- a/core/src/drivers/plugins/python/opcua/opcua_memory.py +++ b/core/src/drivers/plugins/python/opcua/opcua_memory.py @@ -1,9 +1,30 @@ -"""OPC-UA plugin memory access utilities.""" +""" +OPC-UA plugin memory access — STruC++ debugger surface. + +Replaces the MatIEC-era direct-ctypes-pointer fast path. Variable I/O +now goes through the runtime's strucpp_debug_* C function pointers +exposed on plugin_runtime_args_t: + + args.debug_read(arr, elem, dest) → bytes written + args.debug_write(arr, elem, bytes, len) → status (soft write) + args.debug_set(arr, elem, force, ...) → status (force/unforce) + args.debug_size(arr, elem) → bytes for that leaf + +The plugin is variable-position-agnostic: the editor resolves user- +selected variables to (arr, elem) tuples in opcua_config.json and the +plugin forwards them through these calls. No flat-index lookups, no +debug-map.json reads, no direct ctypes pointer arithmetic. + +OPC-UA Writes use debug_write — soft writes that the next scan cycle +can overwrite. Use debug_set(forcing=True) only if exposing a +"force" capability to clients (not the default). +""" import ctypes import os +import struct import sys -from typing import Any, List, Dict +from typing import Any, Dict, Iterable, Optional, Tuple # Add directories to path for module access _current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -13,292 +34,226 @@ # Import local modules (handle both package and direct loading) try: from .opcua_types import VariableMetadata - from .opcua_logging import log_debug, log_error, log_info, log_warn + from .opcua_logging import log_debug, log_error, log_warn except ImportError: from opcua_types import VariableMetadata - from opcua_logging import log_debug, log_error, log_info, log_warn + from opcua_logging import log_debug, log_error, log_warn -# IEC 61131-3 STRING constants (must match iec_types.h) -STR_MAX_LEN = 126 -STR_LEN_SIZE = 1 # sizeof(__strlen_t) = sizeof(int8_t) = 1 -STRING_TOTAL_SIZE = STR_LEN_SIZE + STR_MAX_LEN # 127 bytes - -# IEC 61131-3 TIME/DATE constants (must match iec_types.h) -TIMESPEC_SIZE = 8 # sizeof(IEC_TIMESPEC) = 2 * sizeof(int32_t) = 8 bytes - -# TIME-related datatypes that use IEC_TIMESPEC structure +# TIME-related datatypes are encoded as 8-byte signed integers in +# nanoseconds (matching strucpp's TIME_t / DATE_t / TOD_t / DT_t). TIME_DATATYPES = frozenset(["TIME", "DATE", "TOD", "DT"]) - -def _validate_memory_address(address: int, size: int = 1) -> None: - """ - Validate a memory address before access. - - Args: - address: Memory address to validate - size: Size of data to be accessed (for bounds context) - - Raises: - ValueError: If address is invalid (NULL, negative, or suspiciously small) - """ - if address is None: - raise ValueError("Memory address is None") - if not isinstance(address, int): - raise ValueError(f"Memory address must be an integer, got {type(address).__name__}") - if address == 0: - raise ValueError("Memory address is NULL (0)") - if address < 0: - raise ValueError(f"Memory address is negative: {address}") - # Addresses below 4096 are typically reserved/unmapped on most systems - if address < 4096: - raise ValueError(f"Memory address {address} is in reserved memory region (< 4096)") - - -class IEC_TIMESPEC(ctypes.Structure): - """ - ctypes structure matching IEC_TIMESPEC from iec_types.h. - - typedef struct { - int32_t tv_sec; // Seconds - int32_t tv_nsec; // Nanoseconds - } IEC_TIMESPEC; - - Used for TIME, DATE, TOD, and DT types. +# Maximum byte size for read buffers. Generous bound so a STRING +# (126 bytes + 1 length byte = 127) plus future variable-length +# encodings fit without reallocating per read. +_READ_BUFFER_SIZE = 256 + +# Status code from debug_dispatch.hpp +STATUS_OK = 0x7E + + +def _ctype_for(datatype: str) -> Optional[Any]: + """Map an IEC type name to the ctypes scalar that owns its bytes + on disk. Returns None for variable-length types (STRING/WSTRING) + which need special handling.""" + t = (datatype or "").upper() + if t == "BOOL": + return ctypes.c_uint8 # 1 byte 0/1 + if t == "SINT": + return ctypes.c_int8 + if t in ("USINT", "BYTE"): + return ctypes.c_uint8 + if t == "INT": + return ctypes.c_int16 + if t in ("UINT", "WORD"): + return ctypes.c_uint16 + if t == "DINT": + return ctypes.c_int32 + if t in ("UDINT", "DWORD"): + return ctypes.c_uint32 + if t == "LINT": + return ctypes.c_int64 + if t in ("ULINT", "LWORD"): + return ctypes.c_uint64 + if t == "REAL": + return ctypes.c_float + if t == "LREAL": + return ctypes.c_double + if t in TIME_DATATYPES: + # strucpp encodes time-family types as int64 nanoseconds (TIME_t). + return ctypes.c_int64 + if t in ("STRING", "WSTRING"): + return None # variable-length, not yet supported by debug surface + return None + + +def debug_read_value(args: Any, arr: int, elem: int, datatype: str) -> Optional[Any]: + """Read a single PLC variable through args.debug_read and decode + it into a Python value matching the IEC datatype. + + Returns None on read failure (program not loaded, address out-of- + bounds, type stub not implemented). Callers should treat None as + "skip this variable for the current cycle". """ + ctype = _ctype_for(datatype) + if ctype is None: + # STRING/WSTRING — not supported yet (Phase 4a in + # debug_dispatch.hpp explicitly stubs string reads). + return None - _fields_ = [ - ("tv_sec", ctypes.c_int32), - ("tv_nsec", ctypes.c_int32), - ] - - -class IEC_STRING(ctypes.Structure): - """ - ctypes structure matching IEC_STRING from iec_types.h. - - typedef struct { - __strlen_t len; // int8_t, 1 byte - uint8_t body[126]; // 126 bytes - } IEC_STRING; - """ - _fields_ = [ - ("len", ctypes.c_int8), - ("body", ctypes.c_uint8 * STR_MAX_LEN), - ] - - -def read_memory_direct(address: int, size: int, datatype: str = None) -> Any: - """ - Read value directly from memory using cached address. - - Args: - address: Memory address to read from - size: Size of the variable in bytes - datatype: Optional datatype hint for ambiguous sizes (e.g., TIME vs LINT) - - Returns: - Value read from memory: - - int for numeric types - - str for STRING - - tuple(tv_sec, tv_nsec) for TIME/DATE/TOD/DT - - Raises: - RuntimeError: If memory access fails - ValueError: If size is not supported or address is invalid - """ - # Validate address before any memory access - _validate_memory_address(address, size) - + buf = (ctypes.c_uint8 * _READ_BUFFER_SIZE)() try: - if size == 1: - ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) - return ptr.contents.value - elif size == 2: - ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) - return ptr.contents.value - elif size == 4: - ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) - return ptr.contents.value - elif size == 8: - # Check if this is a TIME-related type - if datatype and datatype.upper() in TIME_DATATYPES: - return read_timespec_direct(address) - ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) - return ptr.contents.value - elif size == STRING_TOTAL_SIZE: - # STRING type: read IEC_STRING structure and decode to Python string - return read_string_direct(address) - else: - raise ValueError(f"Unsupported variable size: {size}") + n = args.debug_read( + ctypes.c_uint8(arr), + ctypes.c_uint16(elem), + ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint8)), + ) except Exception as e: - raise RuntimeError(f"Memory access error: {e}") + log_error(f"debug_read({arr}, {elem}) raised: {e}") + return None + if n == 0: + # Out-of-bounds, no program, or string-stub — skip. + return None + # Reinterpret the leading bytes as the typed scalar. + typed = ctypes.cast(buf, ctypes.POINTER(ctype)).contents + return typed.value -def read_string_direct(address: int) -> str: - """ - Read an IEC_STRING directly from memory. - - Args: - address: Memory address of the IEC_STRING structure - Returns: - Python string decoded from the IEC_STRING +def debug_write_value(args: Any, arr: int, elem: int, datatype: str, value: Any) -> bool: + """Soft-write a Python value to a PLC variable through + args.debug_write. Encodes the value per the IEC datatype, then + forwards to the runtime which calls IECVar::set() on the leaf + (write is silently ignored if the variable is currently forced; + matches the editor's debugger contract). - Raises: - ValueError: If address is invalid - RuntimeError: If memory access fails + Returns True on STATUS_OK, False otherwise. """ - _validate_memory_address(address, STRING_TOTAL_SIZE) + ctype = _ctype_for(datatype) + if ctype is None: + return False try: - ptr = ctypes.cast(address, ctypes.POINTER(IEC_STRING)) - iec_string = ptr.contents - - # Get the actual length (clamped to valid range) - str_len = max(0, min(iec_string.len, STR_MAX_LEN)) - - if str_len == 0: - return "" - - # Extract bytes from body array and decode - raw_bytes = bytes(iec_string.body[:str_len]) - return raw_bytes.decode('utf-8', errors='replace') - - except Exception as e: - raise RuntimeError(f"String memory access error: {e}") - - -def write_string_direct(address: int, value: str) -> bool: - """ - Write a Python string to an IEC_STRING in memory. - - Args: - address: Memory address of the IEC_STRING structure - value: Python string to write - - Returns: - True if successful - - Raises: - ValueError: If address is invalid - RuntimeError: If memory access fails - """ - _validate_memory_address(address, STRING_TOTAL_SIZE) + encoded = ctype(value) + except (TypeError, ValueError) as e: + log_warn(f"debug_write({arr}, {elem}, {datatype}): cannot encode {value!r}: {e}") + return False + raw = bytes(encoded) + buf = (ctypes.c_uint8 * len(raw))(*raw) try: - ptr = ctypes.cast(address, ctypes.POINTER(IEC_STRING)) - iec_string = ptr.contents - - # Encode string to bytes and truncate if necessary - encoded = value.encode('utf-8', errors='replace') - str_len = min(len(encoded), STR_MAX_LEN) - - # Set length - iec_string.len = str_len - - # Copy bytes to body - for i in range(str_len): - iec_string.body[i] = encoded[i] - - # Zero-fill remainder (optional, for cleanliness) - for i in range(str_len, STR_MAX_LEN): - iec_string.body[i] = 0 - - return True - + status = args.debug_write( + ctypes.c_uint8(arr), + ctypes.c_uint16(elem), + ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint8)), + ctypes.c_uint16(len(raw)), + ) except Exception as e: - raise RuntimeError(f"String memory write error: {e}") - - -def read_timespec_direct(address: int) -> tuple[int, int]: - """ - Read an IEC_TIMESPEC directly from memory. - - Args: - address: Memory address of the IEC_TIMESPEC structure + log_error(f"debug_write({arr}, {elem}) raised: {e}") + return False + return status == STATUS_OK - Returns: - Tuple of (tv_sec, tv_nsec) - Raises: - ValueError: If address is invalid - RuntimeError: If memory access fails +def debug_force_value(args: Any, arr: int, elem: int, datatype: str, value: Any) -> bool: + """Force-write a value (debug_set with forcing=True). Pins the + variable until explicitly unforced. Distinct from debug_write + which is a soft write the program can overwrite next cycle. + Exposed for any plugin feature that wants debugger-style pinning. """ - _validate_memory_address(address, TIMESPEC_SIZE) - + ctype = _ctype_for(datatype) + if ctype is None: + return False try: - ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) - timespec = ptr.contents - return (timespec.tv_sec, timespec.tv_nsec) - except Exception as e: - raise RuntimeError(f"Timespec memory access error: {e}") - - -def write_timespec_direct(address: int, tv_sec: int, tv_nsec: int) -> bool: - """ - Write an IEC_TIMESPEC to memory. - - Args: - address: Memory address of the IEC_TIMESPEC structure - tv_sec: Seconds value (int32) - tv_nsec: Nanoseconds value (int32) - - Returns: - True if successful - - Raises: - ValueError: If address is invalid - RuntimeError: If memory access fails - """ - _validate_memory_address(address, TIMESPEC_SIZE) + encoded = ctype(value) + except (TypeError, ValueError) as e: + log_warn(f"debug_force({arr}, {elem}, {datatype}): cannot encode {value!r}: {e}") + return False + raw = bytes(encoded) + buf = (ctypes.c_uint8 * len(raw))(*raw) try: - ptr = ctypes.cast(address, ctypes.POINTER(IEC_TIMESPEC)) - ptr.contents.tv_sec = ctypes.c_int32(tv_sec).value - ptr.contents.tv_nsec = ctypes.c_int32(tv_nsec).value - return True + status = args.debug_set( + ctypes.c_uint8(arr), + ctypes.c_uint16(elem), + ctypes.c_bool(True), + ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint8)), + ctypes.c_uint16(len(raw)), + ) except Exception as e: - raise RuntimeError(f"Timespec memory write error: {e}") + log_error(f"debug_set({arr}, {elem}) raised: {e}") + return False + return status == STATUS_OK -def initialize_variable_cache(sba, indices: List[int]) -> Dict[int, VariableMetadata]: - """Initialize metadata cache for direct memory access.""" +def debug_unforce(args: Any, arr: int, elem: int) -> bool: + """Release a force on a variable (debug_set with forcing=False). + The bytes/len arguments are ignored by the runtime's unforce path + but the C signature still requires them — pass an empty buffer.""" + buf = (ctypes.c_uint8 * 1)() try: - # Try relative imports first (when used as package) - from .opcua_utils import infer_var_type - except ImportError: - # Fallback to absolute imports (when run standalone) - from opcua_utils import infer_var_type - - try: - # Batch: get addresses - addresses, addr_msg = sba.get_var_list(indices) - if addr_msg != "Success": - log_warn(f"Failed to cache addresses: {addr_msg}") - return {} - - # Batch: get sizes - sizes, size_msg = sba.get_var_sizes_batch(indices) - if size_msg != "Success": - log_warn(f"Failed to cache sizes: {size_msg}") - return {} - - # Create cache - cache = {} - for i, var_index in enumerate(indices): - if addresses[i] is not None and sizes[i] > 0: - metadata = VariableMetadata( - index=var_index, - address=addresses[i], - size=sizes[i], - inferred_type=infer_var_type(sizes[i]) - ) - cache[var_index] = metadata - - log_debug(f"Cached metadata for {len(cache)} variables") - return cache - + status = args.debug_set( + ctypes.c_uint8(arr), + ctypes.c_uint16(elem), + ctypes.c_bool(False), + ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint8)), + ctypes.c_uint16(0), + ) except Exception as e: - log_warn(f"Failed to initialize variable cache: {e}") - return {} + log_error(f"debug_set/unforce({arr}, {elem}) raised: {e}") + return False + return status == STATUS_OK + + +def initialize_variable_cache( + args: Any, + addrs: Iterable[Tuple[int, int]], + datatypes: Dict[Tuple[int, int], str], +) -> Dict[Tuple[int, int], VariableMetadata]: + """Build a (arr, elem) → VariableMetadata cache. Sizes come from + the runtime's args.debug_size; types come from the configured + datatype (already known per-variable from opcua_config.json, no + need to query the runtime for them). + + Returns an empty dict if no program is loaded yet — callers should + check for that and retry on the next cycle. Once populated, the + cache replaces per-call args.debug_size lookups in the hot loops. + """ + cache: Dict[Tuple[int, int], VariableMetadata] = {} + for arr, elem in addrs: + try: + size = args.debug_size(ctypes.c_uint8(arr), ctypes.c_uint16(elem)) + except Exception as e: + log_warn(f"debug_size({arr}, {elem}) raised: {e}") + continue + if size == 0: + # Out-of-bounds or string-stub — skip. + continue + datatype = datatypes.get((arr, elem), "UNKNOWN") + cache[(arr, elem)] = VariableMetadata( + arr=arr, + elem=elem, + size=size, + inferred_type=datatype, + ) + if cache: + log_debug(f"Cached size+type metadata for {len(cache)} variables") + return cache + + +def time_to_timespec(value_ns: int) -> Tuple[int, int]: + """Split an int64 nanosecond value into (tv_sec, tv_nsec) for + callers that want to expose TIME-family values as their CODESYS + components rather than raw nanoseconds.""" + if value_ns < 0: + # Mimic CODESYS: negative duration → (-N, 0..-N). Use floor. + sec, nsec = divmod(value_ns, 1_000_000_000) + else: + sec = value_ns // 1_000_000_000 + nsec = value_ns % 1_000_000_000 + return int(sec), int(nsec) + + +def timespec_to_time(tv_sec: int, tv_nsec: int) -> int: + """Compose (tv_sec, tv_nsec) back into an int64 nanosecond value.""" + return int(tv_sec) * 1_000_000_000 + int(tv_nsec) diff --git a/core/src/drivers/plugins/python/opcua/opcua_types.py b/core/src/drivers/plugins/python/opcua/opcua_types.py index 75bf5cf3..0f6aa4e5 100644 --- a/core/src/drivers/plugins/python/opcua/opcua_types.py +++ b/core/src/drivers/plugins/python/opcua/opcua_types.py @@ -1,26 +1,37 @@ """OPC-UA plugin type definitions.""" from dataclasses import dataclass -from typing import Optional, Any +from typing import Any, Optional, Tuple from asyncua.common.node import Node @dataclass class VariableNode: - """Represents an OPC-UA node mapped to a PLC debug variable.""" + """Represents an OPC-UA node mapped to a PLC debug variable. + + Variables are addressed by (arr, elem) — the same (uint8_t, uint16_t) + tuple the runtime's strucpp_debug_* C exports take. Arrays carry the + base address and a length; element i lives at (arr, elem + i). + """ node: Node - debug_var_index: int + arr: int + elem: int datatype: str access_mode: str is_array_element: bool = False - array_index: Optional[int] = None + array_index: Optional[int] = None # 0..length-1 within the array array_length: Optional[int] = None # Length of array (for array nodes only) @dataclass class VariableMetadata: - """Metadata cache for direct memory access.""" - index: int - address: int + """Metadata cache for direct memory access via debug_read/debug_write.""" + arr: int + elem: int size: int inferred_type: str + + @property + def addr(self) -> Tuple[int, int]: + """Convenience accessor for (arr, elem) tuple.""" + return (self.arr, self.elem) diff --git a/core/src/drivers/plugins/python/opcua/plugin.py b/core/src/drivers/plugins/python/opcua/plugin.py index f1ecf1e9..084c76b7 100644 --- a/core/src/drivers/plugins/python/opcua/plugin.py +++ b/core/src/drivers/plugins/python/opcua/plugin.py @@ -11,6 +11,7 @@ """ import asyncio +import logging import os import sys import threading @@ -56,6 +57,50 @@ _loop: Optional[asyncio.AbstractEventLoop] = None +class _PermissionDenialFilter(logging.Filter): + """Quiet asyncua's "Error while processing message" traceback when + the underlying cause is a UaError raised by our pre-read / + pre-write permission callback. + + The permission system in callbacks.py raises ua.UaError to deny a + write — that's the documented asyncua API for rejecting a request. + The wire response is the protocol-correct BadUserAccessDenied + status code. asyncua's process_message however catches the + exception with logger.exception(), so the runtime log gets a full + Python traceback for what is really an expected, well-handled + permission denial. + + This filter drops the traceback for those specific cases — the + one-line WARN log emitted by callbacks.py + ("DENY write for user … on node …") still goes through, so the + operator has the audit trail without the stack-trace noise. + Genuine errors from process_message (decode failures, broken + requests, etc.) keep their traceback. + """ + + _DENIAL_MARKERS = ( + "Access denied:", + "anonymous read not allowed", + "anonymous write not allowed", + "insufficient write permissions", + "no permissions configured", + ) + + def filter(self, record: logging.LogRecord) -> bool: + if record.exc_info: + exc = record.exc_info[1] + if exc is not None: + msg = str(exc) + if any(marker in msg for marker in self._DENIAL_MARKERS): + return False # drop the record + return True + + +def _install_asyncua_log_filter() -> None: + """Install the permission-denial filter on asyncua's processor logger.""" + logging.getLogger("asyncua.server.uaprocessor").addFilter(_PermissionDenialFilter()) + + def init(args_capsule) -> bool: """ Initialize the OPC UA plugin. @@ -85,6 +130,8 @@ def init(args_capsule) -> bool: get_logger().initialize(logging_accessor) log_debug("Logging initialized with runtime accessor") + _install_asyncua_log_filter() + log_info("OPC UA Plugin initialized successfully") return True @@ -156,11 +203,20 @@ def stop_loop() -> bool: Stop the OPC UA server. Uses a two-phase approach: - 1. Graceful (10s): signal the stop event and wait for the async server to - shut down cleanly via its monitor task. - 2. Forced (5s): if the thread is still alive, cancel all asyncio tasks on + 1. Graceful (2s): signal the stop event and wait for the async server to + shut down cleanly via its monitor task. The sync loop preempts + between sync directions, so one cycle (~100 ms) is the typical + graceful exit budget; the 2 s window covers asyncua's internal + teardown of listening sockets / connected clients. + 2. Forced (3s): if the thread is still alive, cancel all asyncio tasks on the event loop and wait again. + The previous timeout (10 s graceful + 5 s forced) made the editor's + Stop button feel broken — the runtime appeared frozen for ~10 s + while asyncua's server.stop() waited on connected clients to + disconnect. The forced path always succeeded eventually, so the + grace period was just dead time. + Returns: True if the server thread stopped, False if it survived both phases. """ @@ -169,19 +225,19 @@ def stop_loop() -> bool: log_info("Stopping OPC UA server...") try: - # Phase 1: Graceful stop -- signal and wait + # Phase 1: Graceful stop — signal and wait briefly. _stop_event.set() if _server_thread and _server_thread.is_alive(): - _server_thread.join(timeout=10.0) + _server_thread.join(timeout=2.0) if _server_thread and _server_thread.is_alive(): - # Phase 2: Force-cancel the asyncio event loop - log_warn("Graceful stop timed out, forcing event loop cancellation...") + # Phase 2: force-cancel the asyncio event loop. + log_warn("Graceful stop did not complete in 2s, forcing cancellation...") loop = _loop if loop is not None and loop.is_running(): loop.call_soon_threadsafe(_cancel_all_tasks, loop) - _server_thread.join(timeout=5.0) + _server_thread.join(timeout=3.0) if _server_thread and _server_thread.is_alive(): log_error("OPC UA server thread did not stop after forced cancellation") diff --git a/core/src/drivers/plugins/python/opcua/server.py b/core/src/drivers/plugins/python/opcua/server.py index 72bed7e2..20a36b33 100644 --- a/core/src/drivers/plugins/python/opcua/server.py +++ b/core/src/drivers/plugins/python/opcua/server.py @@ -98,8 +98,10 @@ def __init__( # Address space builder (initialized in _create_address_space) self.address_space_builder: Optional[AddressSpaceBuilder] = None - # Node mappings (populated by address space builder) - self.variable_nodes: Dict[int, VariableNode] = {} + # Node mappings (populated by address space builder). + # Variables are keyed by (arr, elem) tuples — the same + # address the runtime's debug_read / debug_write thunks take. + self.variable_nodes: Dict[Any, VariableNode] = {} self.node_permissions: Dict[str, Any] = {} self.nodeid_to_variable: Dict[Any, str] = {} @@ -309,12 +311,22 @@ async def _cleanup(self) -> None: """ Clean up resources. - Stops the server and releases resources. + Stops the server and releases resources. asyncua's server.stop() + can block while it waits for connected clients to disconnect + gracefully (a UAExpert client left running across an editor + Stop press is the classic case). Cap that with a short timeout + so the editor's Stop button feels responsive — any clients + that don't disconnect in 1.5s are dropped when the listening + sockets close. """ try: if self.server and self.running: - await self.server.stop() - log_info("OPC-UA server stopped") + try: + await asyncio.wait_for(self.server.stop(), timeout=1.5) + log_info("OPC-UA server stopped") + except asyncio.TimeoutError: + log_warn("OPC-UA server.stop() did not finish in 1.5s; " + "proceeding with cleanup anyway") self.running = False self.server = None @@ -375,10 +387,13 @@ async def _initialize_sync_manager(self) -> bool: True if initialization successful """ try: + # SyncManager talks to the runtime's debug_* C function + # pointers directly via the underlying ctypes struct. + # buffer_accessor.runtime_args exposes that struct. self.sync_manager = SynchronizationManager( - buffer_accessor=self.buffer_accessor, + args=self.buffer_accessor.runtime_args, variable_nodes=self.variable_nodes, - server=self.server # Pass server for subscription support + server=self.server, ) if not await self.sync_manager.initialize(): diff --git a/core/src/drivers/plugins/python/opcua/synchronization.py b/core/src/drivers/plugins/python/opcua/synchronization.py index 6915e790..577a6f74 100644 --- a/core/src/drivers/plugins/python/opcua/synchronization.py +++ b/core/src/drivers/plugins/python/opcua/synchronization.py @@ -1,256 +1,215 @@ """ -OPC UA Synchronization Manager. - -This module provides bidirectional data synchronization between -OPC-UA server nodes and PLC runtime variables. - -Sync Directions: -1. OPC-UA → Runtime: Client writes propagated to PLC -2. Runtime → OPC-UA: PLC values published to clients - -Subscription Support: -- Uses write_attribute_value() with DataValue for optimal notification triggering -- Includes SourceTimestamp from PLC cycle and ServerTimestamp for audit trail -- Automatically triggers data change notifications for subscribed clients +OPC-UA ↔ PLC synchronization loop. + +Drives the bidirectional value sync between the OPC-UA server's +Variable Nodes and the PLC program's variables. Reads PLC values +through args.debug_read; soft-writes client values through +args.debug_write (NOT debug_set — OPC-UA Write is a regular write +that the next scan cycle can overwrite, distinct from debugger +forcing which pins a value indefinitely). + +Variables are addressed by (arr, elem) — the same tuple the editor +resolved against debug-map.json and wrote into opcua_config.json. +The plugin is variable-position-agnostic: it iterates whatever +(arr, elem) pairs the editor handed it and never opens the debug +map itself. """ import asyncio -import os -import sys from datetime import datetime, timezone -from typing import Dict, Any, Optional, Callable +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple -from asyncua import ua, Server - -# Add directories to path for module access -_current_dir = os.path.dirname(os.path.abspath(__file__)) -_parent_dir = os.path.dirname(_current_dir) -if _current_dir not in sys.path: - sys.path.insert(0, _current_dir) -if _parent_dir not in sys.path: - sys.path.insert(0, _parent_dir) +from asyncua import Server, ua # Import local modules (handle both package and direct loading) try: - from .opcua_logging import log_info, log_warn, log_error, log_debug - from .opcua_types import VariableNode, VariableMetadata + from .opcua_logging import log_debug, log_error, log_info + from .opcua_memory import ( + TIME_DATATYPES, + debug_read_value, + debug_write_value, + initialize_variable_cache, + ) + from .opcua_types import VariableMetadata, VariableNode from .opcua_utils import ( - map_plc_to_opcua_type, convert_value_for_opcua, convert_value_for_plc, - TIME_DATATYPES, + map_plc_to_opcua_type, ) - from .opcua_memory import ( - read_memory_direct, +except ImportError: + from opcua_logging import log_debug, log_error, log_info + from opcua_memory import ( + TIME_DATATYPES, + debug_read_value, + debug_write_value, initialize_variable_cache, - write_timespec_direct, - TIME_DATATYPES as MEM_TIME_DATATYPES, ) -except ImportError: - from opcua_logging import log_info, log_warn, log_error, log_debug - from opcua_types import VariableNode, VariableMetadata + from opcua_types import VariableMetadata, VariableNode from opcua_utils import ( - map_plc_to_opcua_type, convert_value_for_opcua, convert_value_for_plc, - TIME_DATATYPES, - ) - from opcua_memory import ( - read_memory_direct, - initialize_variable_cache, - write_timespec_direct, - TIME_DATATYPES as MEM_TIME_DATATYPES, + map_plc_to_opcua_type, ) -from shared import SafeBufferAccess + +# Address tuple type alias for clarity. +Addr = Tuple[int, int] class SynchronizationManager: - """ - Manages bidirectional data synchronization between OPC-UA and PLC runtime. - - Features: - - Unified sync loop (both directions in single cycle) - - Change detection to minimize writes - - Direct memory access optimization when available - - Batch operations for efficiency - - Subscription support with proper timestamps - - Usage: - sync_mgr = SynchronizationManager(buffer_accessor, variable_nodes, server) - await sync_mgr.initialize() - await sync_mgr.run(is_running_callback, cycle_time) + """Bidirectional OPC-UA ↔ PLC value sync. + + Each cycle: + 1. OPC-UA → PLC: read every readwrite Variable Node's current + OPC-UA value and forward it to args.debug_write if it + changed since the last cycle. Force-writes are silently + no-op'd by the runtime when the variable is currently forced + (matches editor debugger semantics). + 2. PLC → OPC-UA: read every Variable Node's underlying PLC + value via args.debug_read, decode per the configured + datatype, and update the OPC-UA node so subscribed clients + get notifications. """ def __init__( self, - buffer_accessor: SafeBufferAccess, - variable_nodes: Dict[int, VariableNode], - server: Optional[Server] = None + args: Any, + variable_nodes: Dict[Addr, VariableNode], + server: Optional[Server] = None, ): """ - Initialize the synchronization manager. - Args: - buffer_accessor: SafeBufferAccess for PLC memory operations - variable_nodes: Dict mapping variable index to VariableNode - server: Optional Server instance for optimized write_attribute_value + args: PluginRuntimeArgs ctypes struct — exposes debug_read, + debug_write, debug_set, debug_size, debug_array_count. + variable_nodes: Map (arr, elem) → VariableNode produced by + AddressSpaceBuilder. + server: Optional asyncua Server for write_attribute_value + fast path with subscription notification timestamps. """ - self.buffer_accessor = buffer_accessor + self.args = args self.variable_nodes = variable_nodes self.server = server - # Optimization: metadata cache for direct memory access - self.variable_metadata: Dict[int, VariableMetadata] = {} - self._direct_memory_access_enabled = False + # Metadata cache (size + datatype) keyed by (arr, elem). Empty + # until the first PLC-loaded cycle reaches initialize. + self.variable_metadata: Dict[Addr, VariableMetadata] = {} - # Change detection cache (var_index -> last_value) - self.opcua_value_cache: Dict[int, Any] = {} + # Last value seen per (arr, elem) — used to skip unchanged + # OPC-UA → PLC writes. Populated lazily. + self.opcua_value_cache: Dict[Addr, Any] = {} - # Readwrite nodes (filtered from variable_nodes) - self._readwrite_nodes: Dict[int, VariableNode] = {} + # Pre-filtered subset of variable_nodes that are writable. + self._readwrite_nodes: Dict[Addr, VariableNode] = {} - # Cycle timestamp for subscription notifications + # Cycle timestamp for OPC-UA subscription notifications. self._cycle_timestamp: Optional[datetime] = None - # Track if we've logged the "no PLC" warning to avoid log spam + # Track no-PLC log to avoid spam. self._logged_no_plc_warning: bool = False - async def initialize(self) -> bool: - """ - Initialize the synchronization manager. - - Sets up: - - Filters readwrite nodes - - Initializes metadata cache for direct memory access (including array elements) + # ----------------------------------------------------------------- + # Setup + # ----------------------------------------------------------------- - Returns: - True if initialization successful - """ + async def initialize(self) -> bool: + """One-time partition: split variable_nodes into readwrite vs + readonly. Metadata cache is built lazily on the first cycle + the PLC reports a non-zero array count.""" try: - # Filter readwrite nodes self._readwrite_nodes = { - var_index: var_node - for var_index, var_node in self.variable_nodes.items() - if var_node.access_mode == "readwrite" + addr: node + for addr, node in self.variable_nodes.items() + if node.access_mode == "readwrite" } - log_debug(f"Sync manager: {len(self._readwrite_nodes)} readwrite nodes, " - f"{len(self.variable_nodes) - len(self._readwrite_nodes)} readonly nodes") - - # Initialize metadata cache for direct memory access - if self.variable_nodes: - # Collect all indices including array elements - var_indices = [] - for var_index, var_node in self.variable_nodes.items(): - if var_node.array_length and var_node.array_length > 0: - # For arrays, add all element indices - for i in range(var_node.array_length): - var_indices.append(var_index + i) - else: - var_indices.append(var_index) - - self.variable_metadata = initialize_variable_cache( - self.buffer_accessor, - var_indices - ) - self._direct_memory_access_enabled = bool(self.variable_metadata) - - if self._direct_memory_access_enabled: - log_debug(f"Direct memory access enabled for {len(self.variable_metadata)} indices") - else: - log_debug("Using batch operations for sync") - + log_debug( + f"Sync manager: {len(self._readwrite_nodes)} readwrite, " + f"{len(self.variable_nodes) - len(self._readwrite_nodes)} readonly" + ) return True - except Exception as e: log_error(f"Failed to initialize sync manager: {e}") return False - async def _reinitialize_metadata(self) -> None: - """ - Reinitialize metadata cache when PLC program becomes available. - - Called when transitioning from no-PLC to PLC-loaded state. - """ - try: - if not self.variable_nodes: - return - - # Collect all indices including array elements - var_indices = [] - for var_index, var_node in self.variable_nodes.items(): - if var_node.array_length and var_node.array_length > 0: - for i in range(var_node.array_length): - var_indices.append(var_index + i) - else: - var_indices.append(var_index) - - self.variable_metadata = initialize_variable_cache( - self.buffer_accessor, - var_indices - ) - self._direct_memory_access_enabled = bool(self.variable_metadata) - - if self._direct_memory_access_enabled: - log_debug(f"Direct memory access enabled for {len(self.variable_metadata)} indices") + def _all_addresses(self) -> list: + """Expand variable_nodes into the full list of (arr, elem) + addresses, including every element of every array.""" + addrs: list = [] + for (base_arr, base_elem), node in self.variable_nodes.items(): + length = node.array_length or 0 + if length > 0: + addrs.extend((base_arr, base_elem + i) for i in range(length)) else: - log_debug("Using batch operations for sync") + addrs.append((base_arr, base_elem)) + return addrs + + def _populate_metadata(self) -> None: + """Build the (arr, elem) → metadata cache. Called when the + plugin transitions from no-PLC to PLC-loaded.""" + addrs = self._all_addresses() + # Build the datatype map from VariableNode (same datatype for + # an array across all its elements). + datatypes: Dict[Addr, str] = {} + for (base_arr, base_elem), node in self.variable_nodes.items(): + length = node.array_length or 0 + if length > 0: + for i in range(length): + datatypes[(base_arr, base_elem + i)] = node.datatype + else: + datatypes[(base_arr, base_elem)] = node.datatype - # Clear value cache to force full sync on next cycle - self.opcua_value_cache.clear() + self.variable_metadata = initialize_variable_cache(self.args, addrs, datatypes) + # Reset value cache so the next cycle pushes everything. + self.opcua_value_cache.clear() - except Exception as e: - log_error(f"Failed to reinitialize metadata: {e}") + # ----------------------------------------------------------------- + # Top-level loop + # ----------------------------------------------------------------- async def run( self, is_running: Callable[[], bool], - cycle_time_seconds: float + cycle_time_seconds: float, ) -> None: - """ - Run the unified synchronization loop. - - Executes both sync directions sequentially in a single cycle: - 1. OPC-UA → Runtime (client writes to PLC) - 2. Runtime → OPC-UA (PLC values to clients) - - Args: - is_running: Callback that returns False when loop should stop - cycle_time_seconds: Time between sync cycles in seconds - """ - log_info(f"Starting sync loop (cycle time: {cycle_time_seconds*1000:.0f}ms)") + """Run the unified sync loop until is_running returns False.""" + log_info(f"Starting sync loop (cycle: {cycle_time_seconds * 1000:.0f}ms)") while is_running(): try: - # Check if PLC program is loaded by checking variable count - # When no program is loaded, get_var_count returns 0 - var_count, _ = self.buffer_accessor.get_var_count() - if var_count == 0: - # No PLC program loaded, skip syncing + # No PLC loaded yet — args.debug_array_count returns 0. + # Skip syncing; clients will see the type-defaulted seed + # values until a program loads. + array_count = self.args.debug_array_count() + if array_count == 0: if not self._logged_no_plc_warning: - log_info("No PLC program loaded, sync paused (waiting for program)") + log_info("No PLC program loaded, sync paused") self._logged_no_plc_warning = True await asyncio.sleep(cycle_time_seconds) continue - # Reset warning flag when PLC is loaded + # Transition no-PLC → PLC-loaded: rebuild metadata + # cache so subsequent cycles know the byte sizes. if self._logged_no_plc_warning: log_info("PLC program detected, resuming sync") self._logged_no_plc_warning = False - # Re-initialize metadata cache now that PLC is loaded - await self._reinitialize_metadata() + if not self.variable_metadata: + self._populate_metadata() + if not self.variable_metadata: + # All addresses still out-of-bounds — try again + # next cycle. + await asyncio.sleep(cycle_time_seconds) + continue - # Capture cycle timestamp for subscription notifications self._cycle_timestamp = datetime.now(timezone.utc) - - # Direction 1: OPC-UA → Runtime await self.sync_opcua_to_runtime() - - # Direction 2: Runtime → OPC-UA + # Mid-cycle preemption point: a stop request between + # the two sync directions exits without doing the + # second pass — keeps shutdown latency bounded by one + # half-cycle instead of a full cycle for projects + # with many variables. + if not is_running(): + break await self.sync_runtime_to_opcua() - - # Wait for next cycle await asyncio.sleep(cycle_time_seconds) except asyncio.CancelledError: @@ -258,389 +217,163 @@ async def run( break except Exception as e: log_error(f"Error in sync loop: {e}") - await asyncio.sleep(0.1) # Brief pause on error + await asyncio.sleep(0.1) log_info("Sync loop stopped") + # ----------------------------------------------------------------- + # OPC-UA → PLC (writes from clients) + # ----------------------------------------------------------------- + async def sync_opcua_to_runtime(self) -> None: - """ - Synchronize values from OPC-UA readwrite nodes to PLC runtime. + """Read every readwrite Variable Node's OPC-UA value and + forward changes to args.debug_write (soft write — respects + existing forces). Skips unchanged values via opcua_value_cache.""" + if not self._readwrite_nodes: + return - Only syncs changed values to minimize PLC writes. - TIME values are written via direct memory access. - """ - try: - if not self._readwrite_nodes: - return - - # Collect values to write (only changed values) - # Separate TIME values (need direct memory access) from regular values - values_to_write = [] - indices_to_write = [] - time_writes = [] # List of (var_index, tv_sec, tv_nsec) tuples - - for var_index, var_node in self._readwrite_nodes.items(): - try: - # Read current value from OPC-UA node - opcua_value = await var_node.node.read_value() - - # Extract actual value - actual_value = self._extract_opcua_value(opcua_value) - if actual_value is None: - continue + for (base_arr, base_elem), node in self._readwrite_nodes.items(): + try: + opcua_value = await node.node.read_value() + actual = self._extract_opcua_value(opcua_value) + if actual is None: + continue - is_time_type = var_node.datatype.upper() in TIME_DATATYPES - - # Check if this is an array node - if var_node.array_length and var_node.array_length > 0: - # Handle array: value should be a list - if isinstance(actual_value, (list, tuple)): - for i, elem_value in enumerate(actual_value): - elem_index = var_index + i - plc_value = convert_value_for_plc(var_node.datatype, elem_value) - - # Check if element has changed - if self._has_value_changed(elem_index, plc_value): - if is_time_type and isinstance(plc_value, tuple): - tv_sec, tv_nsec = plc_value - time_writes.append((elem_index, tv_sec, tv_nsec)) - else: - values_to_write.append(plc_value) - indices_to_write.append(elem_index) - self.opcua_value_cache[elem_index] = plc_value + length = node.array_length or 0 + if length > 0: + if not isinstance(actual, (list, tuple)): continue - - # Handle scalar value - plc_value = convert_value_for_plc(var_node.datatype, actual_value) - - # Check if value has changed - if self._has_value_changed(var_index, plc_value): - if is_time_type and isinstance(plc_value, tuple): - # TIME values need direct memory access - tv_sec, tv_nsec = plc_value - time_writes.append((var_index, tv_sec, tv_nsec)) - else: - values_to_write.append(plc_value) - indices_to_write.append(var_index) - - # Update cache - self.opcua_value_cache[var_index] = plc_value - - except Exception as e: - log_error(f"Error reading OPC-UA variable {var_index}: {e}") + for i, elem_value in enumerate(actual[:length]): + addr = (base_arr, base_elem + i) + plc_value = convert_value_for_plc(node.datatype, elem_value) + if not self._has_value_changed(addr, plc_value): + continue + self._write_one(addr, node.datatype, plc_value) + self.opcua_value_cache[addr] = plc_value continue - # Batch write to PLC if we have changed values - if values_to_write: - await self._write_to_plc_batch(indices_to_write, values_to_write) - - # Write TIME values via direct memory access - if time_writes and self._direct_memory_access_enabled: - for var_index, tv_sec, tv_nsec in time_writes: - try: - metadata = self.variable_metadata.get(var_index) - if metadata: - write_timespec_direct(metadata.address, tv_sec, tv_nsec) - else: - log_warn(f"No metadata for TIME variable {var_index}, skipping write") - except Exception as e: - log_error(f"Failed to write TIME variable {var_index}: {e}") - - except Exception as e: - log_error(f"Error in OPC-UA to runtime sync: {e}") - - async def sync_runtime_to_opcua(self) -> None: - """ - Synchronize values from PLC runtime to OPC-UA nodes. - - Uses direct memory access when available, falls back to batch operations. - """ - try: - if not self.variable_nodes: - return - - if self._direct_memory_access_enabled and self.variable_metadata: - await self._update_via_direct_memory_access() - else: - await self._update_via_batch_operations() - - except Exception as e: - log_error(f"Error in runtime to OPC-UA sync: {e}") - - async def _update_via_direct_memory_access(self) -> None: - """ - Update OPC-UA nodes using direct memory access. - - This is the optimized path - zero C calls per variable. - """ - for var_index, metadata in self.variable_metadata.items(): - try: - var_node = self.variable_nodes.get(var_index) - if not var_node: + # Scalar. + addr = (base_arr, base_elem) + plc_value = convert_value_for_plc(node.datatype, actual) + if not self._has_value_changed(addr, plc_value): continue - - # Direct memory read - pass datatype for TIME handling - value = read_memory_direct( - metadata.address, - metadata.size, - datatype=var_node.datatype - ) - - await self._update_opcua_node(var_node, value) + self._write_one(addr, node.datatype, plc_value) + self.opcua_value_cache[addr] = plc_value except Exception as e: - log_error(f"Direct memory access failed for var {var_index}: {e}") + log_error(f"Error reading OPC-UA variable ({base_arr},{base_elem}): {e}") - async def _update_via_batch_operations(self) -> None: - """ - Update OPC-UA nodes using batch operations. + def _write_one(self, addr: Addr, datatype: str, plc_value: Any) -> None: + """Soft-write one (arr, elem) leaf via args.debug_write. - Fallback when direct memory access is not available. + TIME-family values arrive as (tv_sec, tv_nsec) tuples from + convert_value_for_plc; recombine into the int64 nanosecond + representation strucpp uses on the wire. """ - var_indices = list(self.variable_nodes.keys()) - - # Single batch call for all values - results, msg = self.buffer_accessor.get_var_values_batch(var_indices) + if datatype.upper() in TIME_DATATYPES and isinstance(plc_value, tuple): + tv_sec, tv_nsec = plc_value + plc_value = int(tv_sec) * 1_000_000_000 + int(tv_nsec) + ok = debug_write_value(self.args, addr[0], addr[1], datatype, plc_value) + if not ok: + log_error(f"debug_write({addr[0]}, {addr[1]}) failed") - # Check for actual errors (exceptions during batch operation) - # "Batch read completed" is the success message, not "Success" - if "Exception" in msg or "Error" in msg: - log_error(f"Batch read failed: {msg}") - return + # ----------------------------------------------------------------- + # PLC → OPC-UA (push to clients) + # ----------------------------------------------------------------- - # If no results returned, nothing to do (may happen when no PLC is loaded) - if not results: + async def sync_runtime_to_opcua(self) -> None: + """Read every Variable Node's PLC value via args.debug_read + and update the OPC-UA node so subscribed clients see it.""" + if not self.variable_nodes: return - # Process results - individual items may have failed (e.g., no PLC loaded) - for i, (value, var_msg) in enumerate(results): - var_index = var_indices[i] - var_node = self.variable_nodes.get(var_index) - - if var_msg == "Success" and value is not None and var_node: - await self._update_opcua_node(var_node, value) - - async def _update_opcua_node(self, var_node: VariableNode, value: Any) -> None: - """ - Update an OPC-UA node with a new value. - - Uses write_attribute_value() with DataValue for optimal subscription support. - This approach: - - Triggers data change notifications for subscribed clients - - Includes SourceTimestamp (PLC cycle time) and ServerTimestamp - - Bypasses PreWrite callbacks (server-internal operation) - - Is faster than write_value() for server-side updates - - Args: - var_node: The VariableNode to update - value: Raw value from PLC memory (single value, not used for arrays) - """ - try: - # Check if this is an array node - if var_node.array_length and var_node.array_length > 0: - await self._update_array_node(var_node) - return - - # Convert to OPC-UA format - opcua_value = convert_value_for_opcua(var_node.datatype, value) - - # Get expected OPC-UA type - expected_type = map_plc_to_opcua_type(var_node.datatype) - - # Create Variant with explicit type - variant = ua.Variant(opcua_value, expected_type) - - # Create DataValue with timestamps for subscription notifications - # SourceTimestamp: When the value was read from PLC (cycle time) - # ServerTimestamp: When the server processed it (now) - data_value = ua.DataValue( - Value=variant, - StatusCode_=ua.StatusCode(ua.StatusCodes.Good), - SourceTimestamp=self._cycle_timestamp, - ServerTimestamp=datetime.now(timezone.utc) - ) - - # Use write_attribute_value for optimal subscription triggering - # This is faster than write_value and properly triggers notifications - if self.server: - await self.server.write_attribute_value( - var_node.node.nodeid, - data_value - ) - else: - # Fallback to write_value if no server reference - await var_node.node.write_value(variant) - - except Exception as e: - log_error(f"Failed to update OPC-UA node {var_node.debug_var_index}: {e}") - - async def _update_array_node(self, var_node: VariableNode) -> None: - """ - Update an OPC-UA array node by reading all elements from PLC memory. - - Arrays in PLC have consecutive indices starting from debug_var_index. - Uses DataValue with timestamps for subscription support. + for (base_arr, base_elem), node in self.variable_nodes.items(): + try: + if node.array_length and node.array_length > 0: + await self._update_array_node(node, base_arr, base_elem) + else: + value = debug_read_value( + self.args, base_arr, base_elem, node.datatype + ) + if value is None: + continue + await self._update_opcua_node(node, value) + except Exception as e: + log_error(f"Failed to update node ({base_arr},{base_elem}): {e}") + + async def _update_opcua_node(self, node: VariableNode, value: Any) -> None: + """Push one decoded scalar to its OPC-UA Variable Node.""" + opcua_value = convert_value_for_opcua(node.datatype, value) + expected_type = map_plc_to_opcua_type(node.datatype) + variant = ua.Variant(opcua_value, expected_type) + data_value = ua.DataValue( + Value=variant, + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=self._cycle_timestamp, + ServerTimestamp=datetime.now(timezone.utc), + ) + if self.server: + await self.server.write_attribute_value(node.node.nodeid, data_value) + else: + await node.node.write_value(variant) - Args: - var_node: The VariableNode representing the array - """ - try: - base_index = var_node.debug_var_index - array_length = var_node.array_length - - # Read all array elements from PLC - element_indices = list(range(base_index, base_index + array_length)) - - # Try direct memory access first for array elements - array_values = [] - if self._direct_memory_access_enabled: - for idx in element_indices: - metadata = self.variable_metadata.get(idx) - if metadata: - # Pass datatype for TIME handling - raw_value = read_memory_direct( - metadata.address, - metadata.size, - datatype=var_node.datatype - ) - opcua_value = convert_value_for_opcua(var_node.datatype, raw_value) - array_values.append(opcua_value) - else: - # Fallback: read via buffer accessor - val, msg = self.buffer_accessor.get_var_value(idx) - if msg == "Success" and val is not None: - opcua_value = convert_value_for_opcua(var_node.datatype, val) - array_values.append(opcua_value) - else: - # Use default value - array_values.append(self._get_default_value(var_node.datatype)) - else: - # Use batch operation - results, batch_msg = self.buffer_accessor.get_var_values_batch(element_indices) - for val, msg in results: - if msg == "Success" and val is not None: - opcua_value = convert_value_for_opcua(var_node.datatype, val) - array_values.append(opcua_value) - else: - array_values.append(self._get_default_value(var_node.datatype)) - - # Get expected OPC-UA type - expected_type = map_plc_to_opcua_type(var_node.datatype) - - # Create array Variant - variant = ua.Variant(array_values, expected_type) - - # Create DataValue with timestamps for subscription notifications - data_value = ua.DataValue( - Value=variant, - StatusCode_=ua.StatusCode(ua.StatusCodes.Good), - SourceTimestamp=self._cycle_timestamp, - ServerTimestamp=datetime.now(timezone.utc) + async def _update_array_node( + self, node: VariableNode, base_arr: int, base_elem: int + ) -> None: + """Read every element of the array and push as a single + OPC-UA array variant. Missing elements (None from debug_read) + get a type-default placeholder so the array shape is preserved.""" + length = node.array_length or 0 + values = [] + for i in range(length): + v = debug_read_value( + self.args, base_arr, base_elem + i, node.datatype ) + if v is None: + v = self._get_default_value(node.datatype) + values.append(convert_value_for_opcua(node.datatype, v)) + + expected_type = map_plc_to_opcua_type(node.datatype) + variant = ua.Variant(values, expected_type) + data_value = ua.DataValue( + Value=variant, + StatusCode_=ua.StatusCode(ua.StatusCodes.Good), + SourceTimestamp=self._cycle_timestamp, + ServerTimestamp=datetime.now(timezone.utc), + ) + if self.server: + await self.server.write_attribute_value(node.node.nodeid, data_value) + else: + await node.node.write_value(variant) - # Use write_attribute_value for subscription support - if self.server: - await self.server.write_attribute_value( - var_node.node.nodeid, - data_value - ) - else: - # Fallback to write_value if no server reference - await var_node.node.write_value(variant) - - except Exception as e: - log_error(f"Failed to update array node {var_node.debug_var_index}: {e}") + # ----------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------- - def _get_default_value(self, datatype: str) -> Any: - """Get default value for a datatype.""" - dtype = datatype.upper() + @staticmethod + def _get_default_value(datatype: str) -> Any: + dtype = (datatype or "").upper() if dtype == "BOOL": return False - elif dtype in ["FLOAT", "REAL"]: + if dtype in ("REAL", "LREAL"): return 0.0 - elif dtype == "STRING": + if dtype == "STRING": return "" - elif dtype in TIME_DATATYPES: - return 0 # TIME is represented as milliseconds (Int64) in OPC-UA - else: - return 0 - - async def _write_to_plc_batch( - self, - indices: list, - values: list - ) -> None: - """ - Write values to PLC using batch operation. - - Args: - indices: List of variable indices - values: List of values to write - """ - try: - # Combine into tuples as expected by the API - index_value_pairs = list(zip(indices, values)) - results, msg = self.buffer_accessor.set_var_values_batch(index_value_pairs) - - if msg not in ["Success", "Batch write completed"]: - log_error(f"Batch write to PLC failed: {msg}") - return - - # Check individual results - failed_count = sum(1 for success, _ in results if not success) - if failed_count > 0: - log_error(f"Batch write: {failed_count}/{len(results)} failures") - else: - log_debug(f"Successfully wrote {len(results)} values to PLC") - - except Exception as e: - log_error(f"Error in batch write: {e}") - - def _has_value_changed(self, var_index: int, new_value: Any) -> bool: - """ - Check if a value has changed compared to cached value. - - Args: - var_index: Variable index - new_value: New value to compare + return 0 - Returns: - True if value has changed - """ - if var_index not in self.opcua_value_cache: + def _has_value_changed(self, addr: Addr, new_value: Any) -> bool: + cached = self.opcua_value_cache.get(addr) + if cached is None and addr not in self.opcua_value_cache: return True + if isinstance(new_value, float) and isinstance(cached, float): + return abs(new_value - cached) > 1e-6 + return new_value != cached - cached_value = self.opcua_value_cache[var_index] - - # Float comparison with tolerance - if isinstance(new_value, float) and isinstance(cached_value, float): - return abs(new_value - cached_value) > 1e-6 - - # Tuple comparison for TIME types (tv_sec, tv_nsec) - if isinstance(new_value, tuple) and isinstance(cached_value, tuple): - return new_value != cached_value - - # Exact comparison for other types - return new_value != cached_value - - def _extract_opcua_value(self, opcua_value: Any) -> Any: - """ - Extract actual value from OPC-UA response. - - Args: - opcua_value: Value from OPC-UA node read - - Returns: - Extracted value or None on error - """ + @staticmethod + def _extract_opcua_value(opcua_value: Any) -> Any: try: - # If it's a DataValue with Value attribute, extract it if hasattr(opcua_value, "Value"): return opcua_value.Value - - # Already a plain value return opcua_value - - except Exception as e: - log_error(f"Failed to extract OPC-UA value: {e}") + except Exception: return None diff --git a/core/src/drivers/plugins/python/opcua/user_manager.py b/core/src/drivers/plugins/python/opcua/user_manager.py index 8bcba480..481ca943 100644 --- a/core/src/drivers/plugins/python/opcua/user_manager.py +++ b/core/src/drivers/plugins/python/opcua/user_manager.py @@ -515,6 +515,23 @@ def _authenticate_anonymous(self, profile: Any) -> tuple[Optional[User], Optiona """ Authenticate as anonymous user. + Anonymous role assignment is policy-driven by the user list: + + - When no users are configured (config.users is empty), the + server is effectively single-tenant — there's no privilege + model to enforce, so anonymous gets the highest role + (engineer / Admin). This makes "drop in OPC-UA, set + insecure profile, click connect" work end-to-end without + needing to set up users just to get write access. + - When at least one user is configured, anonymous keeps the + read-only viewer role. The administrator opted into a + user model, so anonymous shouldn't bypass it. + + Either way, per-variable permissions still apply. A variable + whose viewer permission is "rw" is writable by anyone; one + whose engineer permission is "r" is read-only even for the + engineer role. + Args: profile: The security profile @@ -525,11 +542,17 @@ def _authenticate_anonymous(self, profile: Any) -> tuple[Optional[User], Optiona log_warn("Anonymous authentication not allowed for this profile") return None, None - # Anonymous users get viewer role (read-only) - openplc_role = "viewer" + if len(self.config.users) == 0: + # No user model configured — give anonymous full role so + # writes work without having to set up users. + openplc_role = "engineer" + asyncua_role = UserRole.Admin + else: + # Users configured — anonymous is read-only viewer. + openplc_role = "viewer" + asyncua_role = UserRole.User - # Return asyncua User object - return User(role=UserRole.User, name="anonymous"), openplc_role + return User(role=asyncua_role, name="anonymous"), openplc_role def _extract_cert_id(self, certificate: Any) -> Optional[str]: """ diff --git a/core/src/drivers/plugins/python/shared/__init__.py b/core/src/drivers/plugins/python/shared/__init__.py index 049786f9..351efb36 100644 --- a/core/src/drivers/plugins/python/shared/__init__.py +++ b/core/src/drivers/plugins/python/shared/__init__.py @@ -27,7 +27,7 @@ # Component interfaces (for advanced users who want to extend the system) from .component_interfaces import ( IBufferType, IMutexManager, IBufferValidator, IBufferAccessor, - IBatchProcessor, IDebugUtils, IConfigHandler, ISafeBufferAccess + IBatchProcessor, IConfigHandler, ISafeBufferAccess ) __all__ = [ @@ -56,7 +56,7 @@ # Component interfaces (for extension) 'IBufferType', 'IMutexManager', 'IBufferValidator', 'IBufferAccessor', - 'IBatchProcessor', 'IDebugUtils', 'IConfigHandler', 'ISafeBufferAccess', + 'IBatchProcessor', 'IConfigHandler', 'ISafeBufferAccess', # Future extensions # 'EthercatConfig', diff --git a/core/src/drivers/plugins/python/shared/component_interfaces.py b/core/src/drivers/plugins/python/shared/component_interfaces.py index e5cd1bd4..a3f5f865 100644 --- a/core/src/drivers/plugins/python/shared/component_interfaces.py +++ b/core/src/drivers/plugins/python/shared/component_interfaces.py @@ -129,55 +129,6 @@ def process_mixed_operations(self, read_operations: List[Tuple], pass -class IDebugUtils: - """Interface for debug and variable operations""" - - @abstractmethod - def get_var_list(self, indexes: List[int]) -> Tuple[List[int], str]: - """Get addresses for variable indexes. Returns (addresses, error_message)""" - pass - - @abstractmethod - def get_var_size(self, index: int) -> Tuple[int, str]: - """Get size of variable at index. Returns (size, error_message)""" - pass - - @abstractmethod - def get_var_value(self, index: int) -> Tuple[Any, str]: - """Read variable value by index. Returns (value, error_message)""" - pass - - @abstractmethod - def set_var_value(self, index: int, value: Any) -> Tuple[bool, str]: - """Write variable value by index. Returns (success, error_message)""" - pass - - @abstractmethod - def get_var_count(self) -> Tuple[int, str]: - """Get total variable count. Returns (count, error_message)""" - pass - - @abstractmethod - def get_var_info(self, index: int) -> Tuple[Dict, str]: - """Get comprehensive variable info. Returns (info_dict, error_message)""" - pass - - @abstractmethod - def get_var_sizes_batch(self, indexes: List[int]) -> Tuple[List[int], str]: - """Get sizes for multiple variables in batch. Returns (sizes, error_message)""" - pass - - @abstractmethod - def get_var_values_batch(self, indexes: List[int]) -> Tuple[List[Tuple[Any, str]], str]: - """Read multiple variable values in batch. Returns (results, error_message)""" - pass - - @abstractmethod - def set_var_values_batch(self, index_value_pairs: List[Tuple[int, Any]]) -> Tuple[List[Tuple[bool, str]], str]: - """Write multiple variable values in batch. Returns (results, error_message)""" - pass - - class IConfigHandler: """Interface for configuration file operations""" diff --git a/core/src/drivers/plugins/python/shared/debug_utils.py b/core/src/drivers/plugins/python/shared/debug_utils.py deleted file mode 100644 index 9f4043df..00000000 --- a/core/src/drivers/plugins/python/shared/debug_utils.py +++ /dev/null @@ -1,495 +0,0 @@ -""" -Debug Utilities for OpenPLC Python Plugin System - -This module provides debug and variable access utilities. -It handles variable listing, size queries, value reading/writing, and other debug operations. -""" - -import ctypes -from typing import List, Tuple, Dict, Any, Optional -try: - # Try relative imports first (when used as package) - from .component_interfaces import IDebugUtils -except ImportError: - # Fall back to absolute imports (when testing standalone) - from component_interfaces import IDebugUtils - - -class DebugUtils(IDebugUtils): - """ - Provides debug and variable access utilities. - - This class encapsulates all debug-related operations, including variable - discovery, size queries, and direct memory access for debugging purposes. - """ - - def __init__(self, runtime_args): - """ - Initialize the debug utilities. - - Args: - runtime_args: PluginRuntimeArgs instance - """ - self.args = runtime_args - - def get_var_list(self, indexes: List[int]) -> Tuple[List[int], str]: - """ - Get a list of variable addresses for the given indexes. - - Args: - indexes: List of integer indexes to get addresses for - - Returns: - Tuple[List[int], str]: (addresses, error_message) - addresses format: [address1, address2, ...] where each address is an int - """ - if not indexes: - return [], "No indexes provided" - - if not isinstance(indexes, (list, tuple)): - return [], "Indexes must be a list or tuple" - - try: - # Convert Python list to C arrays - num_vars = len(indexes) - indexes_array = (ctypes.c_size_t * num_vars)(*indexes) - result_array = (ctypes.c_void_p * num_vars)() - - # Call the C function - self.args.get_var_list(num_vars, indexes_array, result_array) - - # Convert result back to Python list - addresses = [] - for i in range(num_vars): - addr = result_array[i] - if addr is None: - addresses.append(None) - else: - # Convert void pointer to integer address - addresses.append(ctypes.cast(addr, ctypes.c_void_p).value) - - return addresses, "Success" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return [], f"Exception during get_var_list: {e}" - - def get_var_size(self, index: int) -> Tuple[int, str]: - """ - Get the size of a variable at the given index. - - Args: - index: Integer index of the variable - - Returns: - Tuple[int, str]: (size, error_message) - """ - try: - size = self.args.get_var_size(ctypes.c_size_t(index)) - return size, "Success" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return 0, f"Exception during get_var_size: {e}" - - def get_var_value(self, index: int) -> Tuple[Any, str]: - """ - Read a variable value by index with automatic type handling based on size. - - Args: - index: Integer index of the variable - - Returns: - Tuple[Any, str]: (value, error_message) - """ - try: - # Get variable address and size - addresses, addr_err = self.get_var_list([index]) - if not addresses or addresses[0] is None: - return None, f"Failed to get variable address: {addr_err}" - - size, size_err = self.get_var_size(index) - if size == 0: - return None, f"Failed to get variable size: {size_err}" - - address = addresses[0] - - # Read value based on size (since we can't determine exact type) - if size == 1: - # Could be BOOL, BOOL_O, or SINT - read as unsigned and let user interpret - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) - value = value_ptr.contents.value - return value, "Success" - - elif size == 2: - # 16-bit unsigned integer - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) - value = value_ptr.contents.value - return value, "Success" - - elif size == 4: - # 32-bit unsigned integer (could be TIME) - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) - value = value_ptr.contents.value - return value, "Success" - - elif size == 8: - # 64-bit unsigned integer (could be TIME) - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) - value = value_ptr.contents.value - return value, "Success" - - else: - return None, f"Unsupported variable size: {size}" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return None, f"Exception during get_var_value: {e}" - - def set_var_value(self, index: int, value: Any) -> Tuple[bool, str]: - """ - Write a variable value by index with size-based validation. - - Args: - index: Integer index of the variable - value: Value to write - - Returns: - Tuple[bool, str]: (success, error_message) - """ - try: - # Get variable address and size - addresses, addr_err = self.get_var_list([index]) - if not addresses or addresses[0] is None: - return False, f"Failed to get variable address: {addr_err}" - - size, size_err = self.get_var_size(index) - if size == 0: - return False, f"Failed to get variable size: {size_err}" - - address = addresses[0] - - # Validate value type - if not isinstance(value, (bool, int)): - return False, f"Invalid value type: expected bool or int, got {type(value)}" - - # Convert boolean to integer - if isinstance(value, bool): - value = 1 if value else 0 - - # Validate and write value based on size - if size == 1: - # 8-bit value (BOOL, BOOL_O, or SINT) - if not (0 <= value <= 255): - return False, f"Invalid value: {value} (must be 0-255 for 8-bit)" - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) - value_ptr.contents.value = value - return True, "Success" - - elif size == 2: - # 16-bit unsigned integer - if not (0 <= value <= 65535): - return False, f"Invalid value: {value} (must be 0-65535 for 16-bit)" - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) - value_ptr.contents.value = value - return True, "Success" - - elif size == 4: - # 32-bit unsigned integer - if not (0 <= value <= 4294967295): - return False, f"Invalid value: {value} (must be 0-4294967295 for 32-bit)" - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) - value_ptr.contents.value = value - return True, "Success" - - elif size == 8: - # 64-bit unsigned integer - if not (0 <= value <= 18446744073709551615): - return False, f"Invalid value: {value} (must be 0-18446744073709551615 for 64-bit)" - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) - value_ptr.contents.value = value - return True, "Success" - - else: - return False, f"Unsupported variable size: {size}" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return False, f"Exception during set_var_value: {e}" - - def get_var_count(self) -> Tuple[int, str]: - """ - Get the total number of debug variables available. - - Returns: - Tuple[int, str]: (count, error_message) - """ - try: - count = self.args.get_var_count() - return count, "Success" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return 0, f"Exception during get_var_count: {e}" - - def get_var_info(self, index: int) -> Tuple[Dict, str]: - """ - Get comprehensive information about a variable. - - Args: - index: Integer index of the variable - - Returns: - Tuple[Dict, str]: (info_dict, error_message) - info_dict format: {'address': int, 'size': int, 'inferred_type': str} - """ - try: - # Get variable address - addresses, addr_err = self.get_var_list([index]) - if not addresses or addresses[0] is None: - return {}, f"Failed to get variable address: {addr_err}" - - # Get variable size - size, size_err = self.get_var_size(index) - if size == 0: - return {}, f"Failed to get variable size: {size_err}" - - # Infer type from size - inferred_type = self._infer_var_type_from_size(size) - - info = { - 'address': addresses[0], - 'size': size, - 'inferred_type': inferred_type - } - - return info, "Success" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return {}, f"Exception during get_var_info: {e}" - - def get_var_sizes_batch(self, indexes: List[int]) -> Tuple[List[int], str]: - """ - Get sizes for multiple variables in a single batch operation. - - Args: - indexes: List of integer indexes to get sizes for - - Returns: - Tuple[List[int], str]: (sizes, error_message) - sizes format: [size1, size2, ...] where each size is an int - """ - if not indexes: - return [], "No indexes provided" - - if not isinstance(indexes, (list, tuple)): - return [], "Indexes must be a list or tuple" - - try: - sizes = [] - - # Call get_var_size for each index (could be optimized further if C API supports batch) - for index in indexes: - size, msg = self.get_var_size(index) - if msg == "Success": - sizes.append(size) - else: - sizes.append(0) # Error indicator - - return sizes, "Success" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return [], f"Exception during get_var_sizes_batch: {e}" - - def get_var_values_batch(self, indexes: List[int]) -> Tuple[List[Tuple[Any, str]], str]: - """ - Read multiple variable values in a single batch operation. - - Args: - indexes: List of integer indexes to read values for - - Returns: - Tuple[List[Tuple[Any, str]], str]: (results, error_message) - results format: [(value, error_msg), ...] for each index - """ - if not indexes: - return [], "No indexes provided" - - if not isinstance(indexes, (list, tuple)): - return [], "Indexes must be a list or tuple" - - try: - results = [] - - # Get addresses in batch first - addresses, addr_msg = self.get_var_list(indexes) - if addr_msg != "Success": - # Fallback: individual operations - for index in indexes: - value, msg = self.get_var_value(index) - results.append((value, msg)) - return results, "Partial batch operation completed" - - # Get sizes in batch - sizes, size_msg = self.get_var_sizes_batch(indexes) - if size_msg != "Success": - # Fallback: individual operations - for index in indexes: - value, msg = self.get_var_value(index) - results.append((value, msg)) - return results, "Partial batch operation completed" - - # Read values using cached addresses and sizes - for i, index in enumerate(indexes): - try: - address = addresses[i] - size = sizes[i] - - if address is None or size == 0: - results.append((None, f"Invalid address/size for index {index}")) - continue - - # Direct memory read based on size - if size == 1: - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) - value = value_ptr.contents.value - elif size == 2: - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) - value = value_ptr.contents.value - elif size == 4: - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) - value = value_ptr.contents.value - elif size == 8: - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) - value = value_ptr.contents.value - else: - results.append((None, f"Unsupported variable size: {size}")) - continue - - results.append((value, "Success")) - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - results.append((None, f"Exception reading variable {index}: {e}")) - - return results, "Batch read completed" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return [], f"Exception during get_var_values_batch: {e}" - - def set_var_values_batch(self, index_value_pairs: List[Tuple[int, Any]]) -> Tuple[List[Tuple[bool, str]], str]: - """ - Write multiple variable values in a single batch operation. - - Args: - index_value_pairs: List of (index, value) tuples to write - - Returns: - Tuple[List[Tuple[bool, str]], str]: (results, error_message) - results format: [(success, error_msg), ...] for each pair - """ - if not index_value_pairs: - return [], "No index-value pairs provided" - - if not isinstance(index_value_pairs, (list, tuple)): - return [], "Index-value pairs must be a list or tuple" - - try: - results = [] - indexes = [pair[0] for pair in index_value_pairs] - - # Get addresses in batch first - addresses, addr_msg = self.get_var_list(indexes) - if addr_msg != "Success": - # Fallback: individual operations - for index, value in index_value_pairs: - success, msg = self.set_var_value(index, value) - results.append((success, msg)) - return results, "Partial batch operation completed" - - # Get sizes in batch - sizes, size_msg = self.get_var_sizes_batch(indexes) - if size_msg != "Success": - # Fallback: individual operations - for index, value in index_value_pairs: - success, msg = self.set_var_value(index, value) - results.append((success, msg)) - return results, "Partial batch operation completed" - - # Write values using cached addresses and sizes - for i, (index, value) in enumerate(index_value_pairs): - try: - address = addresses[i] - size = sizes[i] - - if address is None or size == 0: - results.append((False, f"Invalid address/size for index {index}")) - continue - - # Validate value type - if not isinstance(value, (bool, int)): - results.append((False, f"Invalid value type for index {index}: expected bool or int, got {type(value)}")) - continue - - # Convert boolean to integer - if isinstance(value, bool): - value = 1 if value else 0 - - # Write based on size - if size == 1: - if not (0 <= value <= 255): - results.append((False, f"Invalid value for 8-bit: {value}")) - continue - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint8)) - value_ptr.contents.value = value - elif size == 2: - if not (0 <= value <= 65535): - results.append((False, f"Invalid value for 16-bit: {value}")) - continue - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint16)) - value_ptr.contents.value = value - elif size == 4: - if not (0 <= value <= 4294967295): - results.append((False, f"Invalid value for 32-bit: {value}")) - continue - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint32)) - value_ptr.contents.value = value - elif size == 8: - if not (0 <= value <= 18446744073709551615): - results.append((False, f"Invalid value for 64-bit: {value}")) - continue - value_ptr = ctypes.cast(address, ctypes.POINTER(ctypes.c_uint64)) - value_ptr.contents.value = value - else: - results.append((False, f"Unsupported variable size: {size}")) - continue - - results.append((True, "Success")) - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - results.append((False, f"Exception writing variable {index}: {e}")) - - return results, "Batch write completed" - - except (AttributeError, TypeError, ValueError, OSError, MemoryError) as e: - return [], f"Exception during set_var_values_batch: {e}" - - def _infer_var_type_from_size(self, size: int) -> str: - """ - Infer variable type based on size. - - Based on debug.c size mappings: - - BOOL/BOOL_O: sizeof(BOOL) = 1 byte - - SINT: sizeof(SINT) = 1 byte - - TIME: sizeof(TIME) = 4 or 8 bytes - - Args: - size: Size in bytes - - Returns: - str: Inferred type name for debugging - """ - if size == 1: - return "BOOL_OR_SINT" # Cannot distinguish between BOOL and SINT by size alone - elif size == 2: - return "UINT16" - elif size == 4: - return "UINT32_OR_TIME" - elif size == 8: - return "UINT64_OR_TIME" - else: - return "UNKNOWN" diff --git a/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py b/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py index 3d00347d..a4ee0a4f 100644 --- a/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py +++ b/core/src/drivers/plugins/python/shared/plugin_config_decode/opcua_config_model.py @@ -173,13 +173,16 @@ class VariableField: Field within a struct variable. Supports nested fields for complex types (FB instances, nested structs). - When a field has nested fields, its index will be None since only leaf - fields have actual debug variable indices. + When a field has nested fields, its (arr, elem) address will be None + since only leaf fields have addresses in the debugger Entry tables. """ name: str datatype: str - initial_value: Any - index: Optional[int] # None for complex types that have nested fields + # None for complex types that have nested fields. Leaf fields carry + # both — an (arr, elem) tuple addressing the leaf in the runtime's + # debug Entry tables (debug_dispatch.hpp). + arr: Optional[int] + elem: Optional[int] permissions: VariablePermissions fields: Optional[List['VariableField']] = None # Nested fields for complex types @@ -189,8 +192,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'VariableField': try: name = data["name"] datatype = data["datatype"] - initial_value = data["initial_value"] - index = data["index"] # Can be None for complex types + arr = data["arr"] + elem = data["elem"] permissions_data = data["permissions"] except KeyError as e: raise ValueError(f"Missing required field in variable field: {e}") @@ -205,8 +208,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'VariableField': return cls( name=name, datatype=datatype, - initial_value=initial_value, - index=index, + arr=arr, + elem=elem, permissions=permissions, fields=nested_fields ) @@ -244,14 +247,15 @@ def from_dict(cls, data: Dict[str, Any]) -> 'StructVariable': @dataclass class ArrayVariable: - """Array variable configuration.""" + """Array variable configuration. Elements live at (arr, elem + i) + for i in 0..length-1; STruC++ guarantees per-array contiguity.""" node_id: str browse_name: str display_name: str datatype: str length: int - initial_value: Any - index: int + arr: int + elem: int permissions: VariablePermissions @classmethod @@ -263,8 +267,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'ArrayVariable': display_name = data["display_name"] datatype = data["datatype"] length = data["length"] - initial_value = data["initial_value"] - index = data["index"] + arr = data["arr"] + elem = data["elem"] permissions_data = data["permissions"] except KeyError as e: raise ValueError(f"Missing required field in array variable: {e}") @@ -277,21 +281,22 @@ def from_dict(cls, data: Dict[str, Any]) -> 'ArrayVariable': display_name=display_name, datatype=datatype, length=length, - initial_value=initial_value, - index=index, + arr=arr, + elem=elem, permissions=permissions ) @dataclass class SimpleVariable: - """Simple variable configuration.""" + """Simple variable configuration. Address is (arr, elem) into the + runtime's debug Entry tables (debug_dispatch.hpp).""" node_id: str browse_name: str display_name: str datatype: str - initial_value: Any description: str - index: int + arr: int + elem: int permissions: VariablePermissions @classmethod @@ -302,9 +307,9 @@ def from_dict(cls, data: Dict[str, Any]) -> 'SimpleVariable': browse_name = data["browse_name"] display_name = data["display_name"] datatype = data["datatype"] - initial_value = data["initial_value"] description = data["description"] - index = data["index"] + arr = data["arr"] + elem = data["elem"] permissions_data = data["permissions"] except KeyError as e: raise ValueError(f"Missing required field in simple variable: {e}") @@ -316,9 +321,9 @@ def from_dict(cls, data: Dict[str, Any]) -> 'SimpleVariable': browse_name=browse_name, display_name=display_name, datatype=datatype, - initial_value=initial_value, description=description, - index=index, + arr=arr, + elem=elem, permissions=permissions ) @@ -456,25 +461,30 @@ def validate(self) -> None: if len(all_node_ids) != len(set(all_node_ids)): raise ValueError(f"Duplicate node_ids found in plugin '{plugin.name}'") - # Check for duplicate indices - # Helper to collect indices recursively from nested fields - def collect_field_indices(fields: List[VariableField]) -> List[int]: - indices = [] + # Check for duplicate addresses. Variables are addressed by + # (arr, elem) tuples into the runtime's debug Entry tables. + def collect_field_addrs(fields: List[VariableField]) -> List[tuple]: + addrs: List[tuple] = [] for field in fields: - if field.index is not None: # Skip None indices (complex types) - indices.append(field.index) + if field.arr is not None and field.elem is not None: + addrs.append((field.arr, field.elem)) if field.fields: # Recurse into nested fields - indices.extend(collect_field_indices(field.fields)) - return indices + addrs.extend(collect_field_addrs(field.fields)) + return addrs - all_indices = [] - all_indices.extend([var.index for var in address_space.variables]) + all_addrs: List[tuple] = [] + all_addrs.extend([(var.arr, var.elem) for var in address_space.variables]) for struct in address_space.structures: - all_indices.extend(collect_field_indices(struct.fields)) - all_indices.extend([arr.index for arr in address_space.arrays]) - - if len(all_indices) != len(set(all_indices)): - raise ValueError(f"Duplicate indices found in plugin '{plugin.name}'") + all_addrs.extend(collect_field_addrs(struct.fields)) + # Each array claims length consecutive addresses starting + # at (arr, elem). Expand for the duplicate check so an + # array overlapping a scalar is caught. + for ary in address_space.arrays: + for offset in range(ary.length): + all_addrs.append((ary.arr, ary.elem + offset)) + + if len(all_addrs) != len(set(all_addrs)): + raise ValueError(f"Duplicate variable addresses found in plugin '{plugin.name}'") # Validate datatypes # Helper to validate datatypes recursively for nested fields diff --git a/core/src/drivers/plugins/python/shared/plugin_runtime_args.py b/core/src/drivers/plugins/python/shared/plugin_runtime_args.py index 110ff985..98ff81d9 100644 --- a/core/src/drivers/plugins/python/shared/plugin_runtime_args.py +++ b/core/src/drivers/plugins/python/shared/plugin_runtime_args.py @@ -41,10 +41,28 @@ class PluginRuntimeArgs(ctypes.Structure): ("mutex_take", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)), ("mutex_give", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)), ("buffer_mutex", ctypes.c_void_p), - # Variable access functions - ("get_var_list", ctypes.CFUNCTYPE(None, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(ctypes.c_void_p))), - ("get_var_size", ctypes.CFUNCTYPE(ctypes.c_size_t, ctypes.c_size_t)), - ("get_var_count", ctypes.CFUNCTYPE(ctypes.c_uint16)), + # STruC++ debugger variable-access surface. Replaces the + # MatIEC-era flat-index API (get_var_list/get_var_size/ + # get_var_count). Variables are addressed by (arr, elem); the + # editor resolves user-selected variables against debug-map.json + # and writes the tuples into each plugin's per-plugin config. + # debug_set toggles forcing; debug_write does a soft write that + # respects existing forces (the next scan cycle can overwrite). + ("debug_array_count", ctypes.CFUNCTYPE(ctypes.c_uint8)), + ("debug_elem_count", ctypes.CFUNCTYPE(ctypes.c_uint16, ctypes.c_uint8)), + ("debug_size", ctypes.CFUNCTYPE(ctypes.c_uint16, ctypes.c_uint8, ctypes.c_uint16)), + ("debug_read", ctypes.CFUNCTYPE(ctypes.c_uint16, + ctypes.c_uint8, ctypes.c_uint16, + ctypes.POINTER(ctypes.c_uint8))), + ("debug_set", ctypes.CFUNCTYPE(ctypes.c_uint8, + ctypes.c_uint8, ctypes.c_uint16, + ctypes.c_bool, + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_uint16)), + ("debug_write", ctypes.CFUNCTYPE(ctypes.c_uint8, + ctypes.c_uint8, ctypes.c_uint16, + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_uint16)), ("plugin_specific_config_file_path", ctypes.c_char * 256), # Buffer size information ("buffer_size", ctypes.c_int), @@ -61,6 +79,8 @@ class PluginRuntimeArgs(ctypes.Structure): ("journal_write_int", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int)), ("journal_write_dint", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_uint)), ("journal_write_lint", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_ulonglong)), + # PLC base tick time in nanoseconds (mirrors C-side base_tick_ns). + ("base_tick_ns", ctypes.c_ulonglong), ] def validate_pointers(self): diff --git a/core/src/drivers/plugins/python/shared/safe_buffer_access_refactored.py b/core/src/drivers/plugins/python/shared/safe_buffer_access_refactored.py index 22cf048f..737ddef3 100644 --- a/core/src/drivers/plugins/python/shared/safe_buffer_access_refactored.py +++ b/core/src/drivers/plugins/python/shared/safe_buffer_access_refactored.py @@ -17,7 +17,6 @@ from .buffer_validator import BufferValidator from .component_interfaces import ISafeBufferAccess from .config_handler import ConfigHandler - from .debug_utils import DebugUtils from .mutex_manager import MutexManager except ImportError: # Fall back to absolute imports (when testing standalone) @@ -27,7 +26,6 @@ from buffer_validator import BufferValidator from component_interfaces import ISafeBufferAccess from config_handler import ConfigHandler - from debug_utils import DebugUtils from mutex_manager import MutexManager @@ -56,6 +54,10 @@ def __init__(self, runtime_args): Args: runtime_args: PluginRuntimeArgs instance """ + # Expose so plugins (e.g. OPC-UA SyncManager) can reach the + # debug_read / debug_write / debug_size function pointers + # without going through the buffer-access wrapper. + self.runtime_args = runtime_args # Initialize all components self.buffer_types = get_buffer_types() self.mutex_manager = MutexManager(runtime_args) @@ -64,7 +66,6 @@ def __init__(self, runtime_args): runtime_args, self.validator, self.mutex_manager ) self.batch_processor = BatchProcessor(self.buffer_accessor, self.mutex_manager) - self.debug_utils = DebugUtils(runtime_args) self.config_handler = ConfigHandler(runtime_args) # Validate initialization (maintains original behavior) @@ -295,47 +296,18 @@ def batch_mixed_operations( return self.batch_processor.process_mixed_operations(read_operations, write_operations) # ============================================================================ - # Debug/Variable Operations + # Variable access (debug_*) — moved out of SafeBufferAccess. + # + # The MatIEC-era flat-index API (get_var_list / get_var_size / + # get_var_count / get_var_value / get_var_*_batch) is gone. Plugins + # that need to read/write program variables call the runtime's + # debug_read / debug_write / debug_set / debug_size function + # pointers directly via runtime_args.* — see + # opcua/opcua_memory.py for the typed Python helpers + # (debug_read_value, debug_write_value, debug_force_value, + # debug_unforce, initialize_variable_cache). # ============================================================================ - def get_var_list(self, indexes: List[int]) -> Tuple[List[int], str]: - """Get variable addresses for indexes.""" - return self.debug_utils.get_var_list(indexes) - - def get_var_size(self, index: int) -> Tuple[int, str]: - """Get variable size.""" - return self.debug_utils.get_var_size(index) - - def get_var_value(self, index: int) -> Tuple[Any, str]: - """Read variable value by index.""" - return self.debug_utils.get_var_value(index) - - def set_var_value(self, index: int, value: Any) -> Tuple[bool, str]: - """Write variable value by index.""" - return self.debug_utils.set_var_value(index, value) - - def get_var_count(self) -> Tuple[int, str]: - """Get total variable count.""" - return self.debug_utils.get_var_count() - - def get_var_info(self, index: int) -> Tuple[Dict, str]: - """Get variable information.""" - return self.debug_utils.get_var_info(index) - - def get_var_sizes_batch(self, indexes: List[int]) -> Tuple[List[int], str]: - """Get sizes for multiple variables in batch.""" - return self.debug_utils.get_var_sizes_batch(indexes) - - def get_var_values_batch(self, indexes: List[int]) -> Tuple[List[Tuple[Any, str]], str]: - """Read multiple variable values in batch.""" - return self.debug_utils.get_var_values_batch(indexes) - - def set_var_values_batch( - self, index_value_pairs: List[Tuple[int, Any]] - ) -> Tuple[List[Tuple[bool, str]], str]: - """Write multiple variable values in batch.""" - return self.debug_utils.set_var_values_batch(index_value_pairs) - # ============================================================================ # Configuration Operations # ============================================================================ diff --git a/core/src/lib/strucpp_abi.hpp b/core/src/lib/strucpp_abi.hpp new file mode 100644 index 00000000..99863593 --- /dev/null +++ b/core/src/lib/strucpp_abi.hpp @@ -0,0 +1,124 @@ +// strucpp_abi.hpp — runtime-side mirror of the strucpp ABI we walk. +// +// The runtime executable is built ONCE; the .so it loads at runtime +// carries the actual strucpp runtime headers (shipped with the user +// program upload, used by scripts/compile.sh to build the .so). The +// runtime itself does NOT vendor strucpp headers — only this minimal +// set of layout-compatible mirror declarations. +// +// CONTRACT: every type below MUST match the layout strucpp's vendored +// headers expose. The .so's vtables, struct offsets, and enum values +// are all assumed identical. ABI consistency between the runtime and +// the strucpp version a user .so was built against is maintained as +// part of the development cycle — not enforced here. When strucpp's +// ABI version bumps in a breaking way, update this file. +// +// Mirrored from strucpp v0.4.5 (iec_located.hpp + iec_std_lib.hpp). + +#ifndef OPENPLC_STRUCPP_ABI_HPP +#define OPENPLC_STRUCPP_ABI_HPP + +#include +#include + +namespace strucpp { + +// --------------------------------------------------------------------------- +// LocatedVar (mirror of strucpp::LocatedVar, iec_located.hpp) +// --------------------------------------------------------------------------- + +enum class LocatedArea : uint8_t { + Input = 0, // %I + Output = 1, // %Q + Memory = 2 // %M +}; + +enum class LocatedSize : uint8_t { + Bit = 0, + Byte = 1, + Word = 2, + DWord = 3, + LWord = 4 +}; + +struct LocatedVar { + LocatedArea area; + LocatedSize size; + uint16_t byte_index; + uint8_t bit_index; + uint8_t _reserved[3]; + void *pointer; +}; + +// --------------------------------------------------------------------------- +// ProgramBase (mirror of strucpp::ProgramBase, iec_std_lib.hpp) +// +// Polymorphic base. The runtime calls ->run() through a pointer; the +// vtable resolves into the .so's address space (where the actual +// derived class lives). Any extra virtual methods strucpp adds AFTER +// run() are fine — the runtime only calls run() so it doesn't need +// them in the mirror, but we keep them to preserve the vtable slot +// indices. +// +// strucpp v0.4.5 ProgramBase virtuals, in order: +// 0: ~ProgramBase() +// 1: run() +// 2: getRetainVars() const +// 3: getRetainCount() const +// --------------------------------------------------------------------------- + +struct RetainVarInfo; // opaque; we never dereference + +struct ProgramBase { + virtual ~ProgramBase() = default; + virtual void run() = 0; + virtual const RetainVarInfo *getRetainVars() const { return nullptr; } + virtual size_t getRetainCount() const { return 0; } +}; + +// --------------------------------------------------------------------------- +// TaskInstance (mirror of strucpp::TaskInstance, iec_std_lib.hpp) +// --------------------------------------------------------------------------- + +struct TaskInstance { + const char *name; + int64_t interval_ns; + int32_t priority; + ProgramBase **programs; + size_t program_count; +}; + +// --------------------------------------------------------------------------- +// ResourceInstance (mirror of strucpp::ResourceInstance, iec_std_lib.hpp) +// --------------------------------------------------------------------------- + +struct ResourceInstance { + const char *name; + const char *processor; + TaskInstance *tasks; + size_t task_count; +}; + +// --------------------------------------------------------------------------- +// ConfigurationInstance (mirror of strucpp::ConfigurationInstance, +// iec_std_lib.hpp) +// +// Polymorphic. The runtime obtains a ConfigurationInstance* via the +// shim's strucpp_get_config() and walks resources/tasks/programs by +// virtual dispatch. vtable slots, in order: +// 0: ~ConfigurationInstance() +// 1: get_name() const +// 2: get_resources() +// 3: get_resource_count() const +// --------------------------------------------------------------------------- + +struct ConfigurationInstance { + virtual ~ConfigurationInstance() = default; + virtual const char *get_name() const = 0; + virtual ResourceInstance *get_resources() = 0; + virtual size_t get_resource_count() const = 0; +}; + +} // namespace strucpp + +#endif // OPENPLC_STRUCPP_ABI_HPP diff --git a/core/src/plc_app/debug_handler.c b/core/src/plc_app/debug_handler.c index 84f41d6a..53c22643 100644 --- a/core/src/plc_app/debug_handler.c +++ b/core/src/plc_app/debug_handler.c @@ -1,267 +1,381 @@ +/* + * debug_handler.c — STruC++ hierarchical debugger PDU handler. + * + * The Modbus-style function codes (0x41-0x45) are kept for wire compatibility + * with the editor and the Arduino runtime. The payload format uses + * (array_idx: u8, elem_idx: u16) addressing — the editor's debug-map.json + * carries the path → (arr, elem) mapping. + * + * Mirrors the dispatch logic in resources/sources/StrucppBaremetal/ModbusSlave.cpp + * from the editor repo. Linux supports larger PDUs than RTU/Arduino; the cap + * here is the runtime-side MAX_DEBUG_FRAME, not the conservative 1400-byte + * limit the Arduino sketch uses. + */ + +#include + #include "debug_handler.h" #include "image_tables.h" #include "utils/log.h" #include "utils/utils.h" -#include #define MAX_DEBUG_FRAME 4096 -#define MB_FC_DEBUG_INFO 0x41 -#define MB_FC_DEBUG_SET 0x42 -#define MB_FC_DEBUG_GET 0x43 +#define MB_FC_DEBUG_INFO 0x41 +#define MB_FC_DEBUG_SET 0x42 +#define MB_FC_DEBUG_GET 0x43 #define MB_FC_DEBUG_GET_LIST 0x44 -#define MB_FC_DEBUG_GET_MD5 0x45 +#define MB_FC_DEBUG_GET_MD5 0x45 -#define MB_DEBUG_SUCCESS 0x7E -#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 -#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +#define MB_DEBUG_ERROR_NOT_LOADED 0x83 -#define SAME_ENDIANNESS 0 -#define REVERSE_ENDIANNESS 1 +/* Each FC 0x44 request entry is 3 bytes (arr + elem_hi + elem_lo). With + * MAX_DEBUG_FRAME = 4096, 1024 entries fit comfortably plus the response + * body. */ +#define VARIDX_SIZE 1024 -#define VARIDX_SIZE 256 +/* MD5 hex strings are exactly 32 chars + null. Bounding the read both by + * the expected length and pos> 8); - frame[2] = (uint8_t)(variableCount & 0xFF); + return ext_strucpp_debug_array_count != NULL && + ext_strucpp_debug_elem_count != NULL && + ext_strucpp_debug_size != NULL && + ext_strucpp_debug_set != NULL && + ext_strucpp_debug_read != NULL; } -static void debugSetTrace(uint8_t *frame, size_t *frame_len, uint16_t varidx, uint8_t flag, - uint16_t len, void *value) +/* Defense-in-depth bounds check on the array index that arrived over the + * wire. The editor's STruC++ codegen validates `arr` inside its debug + * thunks, but a malformed .so could OOB-read its internal table. Runtime + * gate first: reject `arr >= array_count` here so the .so only sees + * indices it claimed to support. */ +static inline bool debug_arr_in_range(uint8_t arr) { - uint16_t variableCount = ext_get_var_count(); - if (varidx >= variableCount || len > (MAX_DEBUG_FRAME - 7)) - { - *frame_len = 2; - frame[0] = MB_FC_DEBUG_SET; - frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } + return arr < ext_strucpp_debug_array_count(); +} - ext_set_trace((size_t)varidx, (bool)flag, value); +static inline void write_u32_be(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)((v >> 24) & 0xFF); + p[1] = (uint8_t)((v >> 16) & 0xFF); + p[2] = (uint8_t)((v >> 8) & 0xFF); + p[3] = (uint8_t)(v & 0xFF); +} +static inline void write_u16_be(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)((v >> 8) & 0xFF); + p[1] = (uint8_t)(v & 0xFF); +} + +static inline uint16_t read_u16_be(const uint8_t *p) +{ + return ((uint16_t)p[0] << 8) | (uint16_t)p[1]; +} + +static void respond_short(uint8_t *frame, size_t *frame_len, uint8_t fc, uint8_t status) +{ + frame[0] = fc; + frame[1] = status; *frame_len = 2; - frame[0] = MB_FC_DEBUG_SET; - frame[1] = MB_DEBUG_SUCCESS; } -static void debugGetTrace(uint8_t *frame, size_t *frame_len, uint16_t startidx, uint16_t endidx) +/* FC 0x41 — DEBUG_INFO */ +static void debugInfo(uint8_t *frame, size_t *frame_len) { - uint16_t variableCount = ext_get_var_count(); - if (startidx >= variableCount || endidx >= variableCount || startidx > endidx) + if (!debug_symbols_ready()) { - *frame_len = 2; - frame[0] = MB_FC_DEBUG_GET; - frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + respond_short(frame, frame_len, MB_FC_DEBUG_INFO, MB_DEBUG_ERROR_NOT_LOADED); return; } - uint16_t lastVarIdx = startidx; - size_t responseSize = 0; - uint8_t *responsePtr = &(frame[10]); + uint8_t arr_count = ext_strucpp_debug_array_count(); + uint8_t max_arrs = (uint8_t)((MAX_DEBUG_FRAME - 3) / 2); + if (arr_count > max_arrs) arr_count = max_arrs; - for (uint16_t varidx = startidx; varidx <= endidx; varidx++) + frame[0] = MB_FC_DEBUG_INFO; + frame[1] = arr_count; + frame[2] = MB_DEBUG_SUCCESS; + + size_t pos = 3; + for (uint8_t a = 0; a < arr_count; ++a) { - size_t varSize = ext_get_var_size(varidx); - if ((responseSize + 10) + varSize <= MAX_DEBUG_FRAME) - { - void *varAddr = ext_get_var_addr(varidx); + uint16_t c = ext_strucpp_debug_elem_count(a); + write_u16_be(&frame[pos], c); + pos += 2; + } + *frame_len = pos; +} - memcpy(responsePtr, varAddr, varSize); +/* FC 0x42 — DEBUG_SET (force / unforce a variable) */ +static void debugSetTrace(uint8_t *frame, size_t *frame_len, size_t length) +{ + if (!debug_symbols_ready()) + { + respond_short(frame, frame_len, MB_FC_DEBUG_SET, MB_DEBUG_ERROR_NOT_LOADED); + return; + } + if (length < 7) + { + respond_short(frame, frame_len, MB_FC_DEBUG_SET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } - responsePtr += varSize; - responseSize += varSize; + uint8_t arr = frame[1]; + uint16_t elem = read_u16_be(&frame[2]); + uint8_t force = frame[4]; + uint16_t val_len = read_u16_be(&frame[5]); + const uint8_t *val_ptr = (val_len > 0) ? &frame[7] : NULL; - lastVarIdx = varidx; - } - else - { - break; - } + if (!debug_arr_in_range(arr)) + { + respond_short(frame, frame_len, MB_FC_DEBUG_SET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } + if (val_len > (MAX_DEBUG_FRAME - 7)) + { + respond_short(frame, frame_len, MB_FC_DEBUG_SET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } + if (length < (size_t)(7 + val_len)) + { + respond_short(frame, frame_len, MB_FC_DEBUG_SET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; } - *frame_len = 10 + responseSize; - frame[0] = MB_FC_DEBUG_GET; - frame[1] = MB_DEBUG_SUCCESS; - frame[2] = (uint8_t)(lastVarIdx >> 8); - frame[3] = (uint8_t)(lastVarIdx & 0xFF); - frame[4] = (uint8_t)((tick__ >> 24) & 0xFF); - frame[5] = (uint8_t)((tick__ >> 16) & 0xFF); - frame[6] = (uint8_t)((tick__ >> 8) & 0xFF); - frame[7] = (uint8_t)(tick__ & 0xFF); - frame[8] = (uint8_t)(responseSize >> 8); - frame[9] = (uint8_t)(responseSize & 0xFF); + uint8_t status = ext_strucpp_debug_set(arr, elem, force != 0, val_ptr, val_len); + respond_short(frame, frame_len, MB_FC_DEBUG_SET, status); } -static void debugGetTraceList(uint8_t *frame, size_t *frame_len, uint16_t numIndexes, - uint8_t *indexArray) +/* FC 0x43 — DEBUG_GET (read a contiguous range from one array) */ +static void debugGetTrace(uint8_t *frame, size_t *frame_len, size_t length) { - uint16_t response_idx = 10; - uint16_t responseSize = 0; - uint16_t lastVarIdx = 0; - uint16_t variableCount = ext_get_var_count(); + if (!debug_symbols_ready()) + { + respond_short(frame, frame_len, MB_FC_DEBUG_GET, MB_DEBUG_ERROR_NOT_LOADED); + return; + } + if (length < 6) + { + respond_short(frame, frame_len, MB_FC_DEBUG_GET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } - uint16_t varidx_array[VARIDX_SIZE]; + uint8_t arr = frame[1]; + uint16_t start = read_u16_be(&frame[2]); + uint16_t end = read_u16_be(&frame[4]); - if (numIndexes > VARIDX_SIZE) + if (!debug_arr_in_range(arr)) { - *frame_len = 2; - frame[0] = MB_FC_DEBUG_GET_LIST; - frame[1] = MB_DEBUG_ERROR_OUT_OF_MEMORY; + respond_short(frame, frame_len, MB_FC_DEBUG_GET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); return; } + uint16_t arr_len = ext_strucpp_debug_elem_count(arr); - for (uint16_t i = 0; i < numIndexes; i++) + if (arr_len == 0 || start >= arr_len || end >= arr_len || start > end) { - varidx_array[i] = (uint16_t)indexArray[i * 2] << 8 | indexArray[i * 2 + 1]; + respond_short(frame, frame_len, MB_FC_DEBUG_GET, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; } - for (uint16_t i = 0; i < numIndexes; i++) + /* Header layout (matches Arduino): + * [0] FC + * [1] STATUS + * [2-3] last_elem_idx (u16 BE) + * [4-7] tick (u32 BE) + * [8-9] response_size (u16 BE) + * [10..] data + */ + const size_t HDR = 10; + uint16_t last_elem = start; + size_t response_sz = 0; + uint8_t *write_ptr = &frame[HDR]; + + for (uint16_t e = start; e <= end; ++e) { - if (varidx_array[i] >= variableCount) + uint16_t var_size = ext_strucpp_debug_size(arr, e); + if (var_size == 0) { - *frame_len = 2; - frame[0] = MB_FC_DEBUG_GET_LIST; - frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; + last_elem = e; + continue; } + if (HDR + response_sz + var_size > MAX_DEBUG_FRAME) break; - size_t varSize = ext_get_var_size(varidx_array[i]); - - if (response_idx + varSize <= MAX_DEBUG_FRAME) + uint16_t n = ext_strucpp_debug_read(arr, e, write_ptr); + if (n == 0) { - void *varAddr = ext_get_var_addr(varidx_array[i]); - memcpy(&frame[response_idx], varAddr, varSize); - response_idx += varSize; - responseSize += varSize; - - lastVarIdx = varidx_array[i]; - } - else - { - break; + last_elem = e; + continue; } + write_ptr += n; + response_sz += n; + last_elem = e; } - *frame_len = response_idx; - frame[0] = MB_FC_DEBUG_GET_LIST; - frame[1] = MB_DEBUG_SUCCESS; - frame[2] = (uint8_t)(lastVarIdx >> 8); - frame[3] = (uint8_t)(lastVarIdx & 0xFF); - frame[4] = (uint8_t)((tick__ >> 24) & 0xFF); - frame[5] = (uint8_t)((tick__ >> 16) & 0xFF); - frame[6] = (uint8_t)((tick__ >> 8) & 0xFF); - frame[7] = (uint8_t)(tick__ & 0xFF); - frame[8] = (uint8_t)(responseSize >> 8); - frame[9] = (uint8_t)(responseSize & 0xFF); + frame[0] = MB_FC_DEBUG_GET; + frame[1] = MB_DEBUG_SUCCESS; + write_u16_be(&frame[2], last_elem); + write_u32_be(&frame[4], (uint32_t)scan_counter); + write_u16_be(&frame[8], (uint16_t)response_sz); + *frame_len = HDR + response_sz; } -static void debugGetMd5(uint8_t *frame, size_t *frame_len, void *endianness) +/* FC 0x44 — DEBUG_GET_LIST (cross-array batch read). + * + * The request and response both live in `frame[]`. Once we start writing + * the response body, we'd clobber later request entries. Snapshot the + * request first — same trick the Arduino sketch uses. */ +static void debugGetTraceList(uint8_t *frame, size_t *frame_len, size_t length) { - uint16_t endian_check = 0; - memcpy(&endian_check, endianness, 2); - if (endian_check == 0xDEAD) + if (!debug_symbols_ready()) { - ext_set_endianness(SAME_ENDIANNESS); + respond_short(frame, frame_len, MB_FC_DEBUG_GET_LIST, MB_DEBUG_ERROR_NOT_LOADED); + return; } - else if (endian_check == 0xADDE) + if (length < 3) { - ext_set_endianness(REVERSE_ENDIANNESS); + respond_short(frame, frame_len, MB_FC_DEBUG_GET_LIST, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } + + uint16_t num_indexes = read_u16_be(&frame[1]); + if (num_indexes == 0) + { + respond_short(frame, frame_len, MB_FC_DEBUG_GET_LIST, MB_DEBUG_ERROR_OUT_OF_BOUNDS); + return; + } + if (num_indexes > VARIDX_SIZE) + { + respond_short(frame, frame_len, MB_FC_DEBUG_GET_LIST, MB_DEBUG_ERROR_OUT_OF_MEMORY); + return; } - else + if (length < (size_t)(3 + (size_t)num_indexes * 3)) { - *frame_len = 2; - frame[0] = MB_FC_DEBUG_GET_MD5; - frame[1] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + respond_short(frame, frame_len, MB_FC_DEBUG_GET_LIST, MB_DEBUG_ERROR_OUT_OF_BOUNDS); return; } - frame[0] = MB_FC_DEBUG_GET_MD5; - frame[1] = MB_DEBUG_SUCCESS; + uint8_t local_index[VARIDX_SIZE * 3]; + memcpy(local_index, &frame[3], (size_t)num_indexes * 3); + + const size_t HDR = 10; + uint16_t last_req_idx = 0; + size_t response_sz = 0; + uint8_t *write_ptr = &frame[HDR]; - int md5_len = 0; - for (md5_len = 0; ext_plc_program_md5[md5_len] != '\0'; md5_len++) + for (uint16_t i = 0; i < num_indexes; ++i) { - frame[md5_len + 2] = ext_plc_program_md5[md5_len]; + uint8_t arr = local_index[i * 3 + 0]; + uint16_t elem = ((uint16_t)local_index[i * 3 + 1] << 8) | local_index[i * 3 + 2]; + + if (!debug_arr_in_range(arr)) + { + last_req_idx = i; + continue; + } + + uint16_t var_size = ext_strucpp_debug_size(arr, elem); + if (var_size == 0) + { + last_req_idx = i; + continue; + } + if (HDR + response_sz + var_size > MAX_DEBUG_FRAME) break; + + uint16_t n = ext_strucpp_debug_read(arr, elem, write_ptr); + if (n == 0) + { + last_req_idx = i; + continue; + } + write_ptr += n; + response_sz += n; + last_req_idx = i; } - *frame_len = md5_len + 2; + frame[0] = MB_FC_DEBUG_GET_LIST; + frame[1] = MB_DEBUG_SUCCESS; + write_u16_be(&frame[2], last_req_idx); + write_u32_be(&frame[4], (uint32_t)scan_counter); + write_u16_be(&frame[8], (uint16_t)response_sz); + *frame_len = HDR + response_sz; } -size_t process_debug_data(uint8_t *data, size_t length) +/* FC 0x45 — DEBUG_GET_MD5 + * + * The trailer carries a runtime-driven endianness sentinel, not an echo of + * what the editor sent. The variable-data path is pure memcpy on both + * sides (the strucpp dispatcher does no byte-order adaptation), so wire + * bytes for force / read are always in target-native order. To let the + * editor know what "native" means here, this handler writes the literal + * value 0xDEAD via a native uint16_t store; the two bytes that land in the + * frame reflect the target's byte order: + * + * LE target → trailer = [0xAD, 0xDE] + * BE target → trailer = [0xDE, 0xAD] + * + * The editor uses that to decide whether to byte-swap variable data at its + * end. The probe bytes in the request are ignored — the trailer is a + * sentinel, not an echo. + */ +static void debugGetMd5(uint8_t *frame, size_t *frame_len, size_t length) { - if (length < 1) + if (length < 3 || ext_strucpp_program_md5 == NULL) { - log_error("Debug data too short"); - return 0; + respond_short(frame, frame_len, MB_FC_DEBUG_GET_MD5, MB_DEBUG_ERROR_NOT_LOADED); + return; } - uint8_t fcode = data[0]; - uint16_t field1 = 0; - uint16_t field2 = 0; - uint8_t flag = 0; - uint16_t len = 0; - void *value = NULL; - void *endianness_check = NULL; + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = MB_DEBUG_SUCCESS; - if (length >= 3) - { - field1 = (uint16_t)data[1] << 8 | (uint16_t)data[2]; - } - if (length >= 5) - { - field2 = (uint16_t)data[3] << 8 | (uint16_t)data[4]; - } - if (length >= 4) - { - flag = data[3]; - } - if (length >= 6) + size_t pos = 2; + for (size_t i = 0; + i < MD5_HEX_LEN && ext_strucpp_program_md5[i] != '\0' && pos < MAX_DEBUG_FRAME - 2; + ++i) { - len = (uint16_t)data[4] << 8 | (uint16_t)data[5]; + frame[pos++] = (uint8_t)ext_strucpp_program_md5[i]; } - if (length >= 7) + if (pos + 2 <= MAX_DEBUG_FRAME) { - value = &data[6]; + /* Native-order store of the endianness sentinel — the resulting + * bytes reveal the target's byte order to the editor. */ + *(uint16_t *)&frame[pos] = 0xDEAD; + pos += 2; } - if (length >= 2) + *frame_len = pos; +} + +size_t process_debug_data(uint8_t *data, size_t length) +{ + if (length < 1) { - endianness_check = &data[1]; + log_error("[debug] frame too short"); + return 0; } - size_t response_len = 0; + size_t response_len = 0; + uint8_t fcode = data[0]; switch (fcode) { - case MB_FC_DEBUG_INFO: - debugInfo(data, &response_len); - break; - - case MB_FC_DEBUG_GET: - debugGetTrace(data, &response_len, field1, field2); - break; - - case MB_FC_DEBUG_GET_LIST: - debugGetTraceList(data, &response_len, field1, &data[3]); - break; - - case MB_FC_DEBUG_SET: - debugSetTrace(data, &response_len, field1, flag, len, value); - break; - - case MB_FC_DEBUG_GET_MD5: - debugGetMd5(data, &response_len, endianness_check); - break; - + case MB_FC_DEBUG_INFO: debugInfo(data, &response_len); break; + case MB_FC_DEBUG_SET: debugSetTrace(data, &response_len, length); break; + case MB_FC_DEBUG_GET: debugGetTrace(data, &response_len, length); break; + case MB_FC_DEBUG_GET_LIST: debugGetTraceList(data, &response_len, length); break; + case MB_FC_DEBUG_GET_MD5: debugGetMd5(data, &response_len, length); break; default: - log_error("Unknown debug function code: 0x%02X", fcode); + log_error("[debug] unknown function code 0x%02X", fcode); return 0; } - log_debug("Processed debug function 0x%02X, response length: %zu", fcode, response_len); + log_debug("[debug] FC 0x%02X processed, response_len=%zu", fcode, response_len); return response_len; } diff --git a/core/src/plc_app/image_tables.c b/core/src/plc_app/image_tables.c deleted file mode 100644 index 28a3c7bf..00000000 --- a/core/src/plc_app/image_tables.c +++ /dev/null @@ -1,432 +0,0 @@ -#include -#include - -#include "image_tables.h" -#include "include/iec_python.h" -#include "log.h" -#include "utils.h" - -// Internal buffers for I/O and memory. -// Booleans -IEC_BOOL *bool_input[BUFFER_SIZE][8]; -IEC_BOOL *bool_output[BUFFER_SIZE][8]; - -// Bytes -IEC_BYTE *byte_input[BUFFER_SIZE]; -IEC_BYTE *byte_output[BUFFER_SIZE]; - -// Analog I/O -IEC_UINT *int_input[BUFFER_SIZE]; -IEC_UINT *int_output[BUFFER_SIZE]; - -// 32bit I/O -IEC_UDINT *dint_input[BUFFER_SIZE]; -IEC_UDINT *dint_output[BUFFER_SIZE]; - -// 64bit I/O -IEC_ULINT *lint_input[BUFFER_SIZE]; -IEC_ULINT *lint_output[BUFFER_SIZE]; - -// Memory -IEC_UINT *int_memory[BUFFER_SIZE]; -IEC_UDINT *dint_memory[BUFFER_SIZE]; -IEC_ULINT *lint_memory[BUFFER_SIZE]; -IEC_BOOL *bool_memory[BUFFER_SIZE][8]; - -void (*ext_config_run__)(unsigned long tick); -void (*ext_config_init__)(void); -void (*ext_glueVars)(void); -void (*ext_updateTime)(void); -void (*ext_setBufferPointers)( - IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], - IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], - IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], - IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], - IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], - IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], - IEC_ULINT *lint_memory[BUFFER_SIZE]); - -// v4 version with bool_memory support for %MX locations -void (*ext_setBufferPointers_v4)( - IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], - IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], - IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], - IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], - IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], - IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], - IEC_ULINT *lint_memory[BUFFER_SIZE], IEC_BOOL *memory_bool[BUFFER_SIZE][8]); - -// Debug -void (*ext_set_endianness)(uint8_t value); -uint16_t (*ext_get_var_count)(void); -size_t (*ext_get_var_size)(size_t idx); -void *(*ext_get_var_addr)(size_t idx); -void (*ext_set_trace)(size_t idx, bool forced, void *val); - -int symbols_init(PluginManager *pm) -{ - // Get pointer to external functions - *(void **)(&ext_config_run__) = - plugin_manager_get_func(pm, void (*)(unsigned long), "config_run__"); - - *(void **)(&ext_config_init__) = - plugin_manager_get_func(pm, void (*)(unsigned long), "config_init__"); - - *(void **)(&ext_glueVars) = plugin_manager_get_func(pm, void (*)(unsigned long), "glueVars"); - - *(void **)(&ext_updateTime) = - plugin_manager_get_func(pm, void (*)(unsigned long), "updateTime"); - - *(void **)(&ext_setBufferPointers) = - plugin_manager_get_func(pm, void (*)(unsigned long), "setBufferPointers"); - - // Try to load v4 version with bool_memory support (optional - only present in v4 programs) - *(void **)(&ext_setBufferPointers_v4) = - plugin_manager_get_func(pm, void (*)(unsigned long), "setBufferPointers_v4"); - - *(void **)(&ext_common_ticktime__) = - plugin_manager_get_func(pm, void (*)(unsigned long), "common_ticktime__"); - - *(void **)(&ext_plc_program_md5) = - plugin_manager_get_func(pm, char *(*)(unsigned long), "plc_program_md5"); - - *(void **)(&ext_set_endianness) = - plugin_manager_get_func(pm, void (*)(unsigned long), "set_endianness"); - - *(void **)(&ext_get_var_count) = - plugin_manager_get_func(pm, uint16_t (*)(uint16_t), "get_var_count"); - - *(void **)(&ext_get_var_size) = plugin_manager_get_func(pm, size_t (*)(size_t), "get_var_size"); - - *(void **)(&ext_get_var_addr) = - plugin_manager_get_func(pm, void *(*)(unsigned long), "get_var_addr"); - - *(void **)(&ext_set_trace) = plugin_manager_get_func(pm, void (*)(unsigned long), "set_trace"); - - // Check if all symbols were loaded successfully - if (!ext_config_run__ || !ext_config_init__ || !ext_glueVars || !ext_updateTime || - !ext_setBufferPointers || !ext_common_ticktime__ || !ext_plc_program_md5 || - !ext_set_endianness || !ext_get_var_count || !ext_get_var_size || !ext_get_var_addr || - !ext_set_trace) - { - log_error("Failed to load all symbols"); - return -1; - } - - // Send buffer pointers to .so - // Try v4 version first (with bool_memory support for %MX), fall back to v1 for older programs - if (ext_setBufferPointers_v4) - { - log_info("Using setBufferPointers_v4 with bool_memory support"); - ext_setBufferPointers_v4(bool_input, bool_output, byte_input, byte_output, int_input, - int_output, dint_input, dint_output, lint_input, lint_output, - int_memory, dint_memory, lint_memory, bool_memory); - } - else - { - log_info("Using setBufferPointers (legacy, no bool_memory support)"); - ext_setBufferPointers(bool_input, bool_output, byte_input, byte_output, int_input, - int_output, dint_input, dint_output, lint_input, lint_output, - int_memory, dint_memory, lint_memory); - } - - // Initialize Python loader logging callbacks (optional - only present if Python FBs are used) - void (*ext_python_loader_set_loggers)(void (*)(const char *, ...), void (*)(const char *, ...)); - *(void **)(&ext_python_loader_set_loggers) = - plugin_manager_get_func(pm, void (*)(unsigned long), "python_loader_set_loggers"); - if (ext_python_loader_set_loggers) - { - ext_python_loader_set_loggers(log_info, log_error); - log_info("Python loader logging callbacks initialized"); - } - - return 0; -} - -// Static backing arrays for NULL pointer fill -// These provide temporary storage for image table entries not used by the PLC program -static IEC_BOOL temp_bool_input[BUFFER_SIZE][8]; -static IEC_BOOL temp_bool_output[BUFFER_SIZE][8]; -static IEC_BYTE temp_byte_input[BUFFER_SIZE]; -static IEC_BYTE temp_byte_output[BUFFER_SIZE]; -static IEC_UINT temp_int_input[BUFFER_SIZE]; -static IEC_UINT temp_int_output[BUFFER_SIZE]; -static IEC_UDINT temp_dint_input[BUFFER_SIZE]; -static IEC_UDINT temp_dint_output[BUFFER_SIZE]; -static IEC_ULINT temp_lint_input[BUFFER_SIZE]; -static IEC_ULINT temp_lint_output[BUFFER_SIZE]; -static IEC_UINT temp_int_memory[BUFFER_SIZE]; -static IEC_UDINT temp_dint_memory[BUFFER_SIZE]; -static IEC_ULINT temp_lint_memory[BUFFER_SIZE]; -static IEC_BOOL temp_bool_memory[BUFFER_SIZE][8]; - -void image_tables_fill_null_pointers(void) -{ - int filled_count = 0; - - // Fill boolean input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - if (bool_input[i][b] == NULL) - { - temp_bool_input[i][b] = 0; - bool_input[i][b] = &temp_bool_input[i][b]; - filled_count++; - } - } - } - - // Fill boolean output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - if (bool_output[i][b] == NULL) - { - temp_bool_output[i][b] = 0; - bool_output[i][b] = &temp_bool_output[i][b]; - filled_count++; - } - } - } - - // Fill byte input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (byte_input[i] == NULL) - { - temp_byte_input[i] = 0; - byte_input[i] = &temp_byte_input[i]; - filled_count++; - } - } - - // Fill byte output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (byte_output[i] == NULL) - { - temp_byte_output[i] = 0; - byte_output[i] = &temp_byte_output[i]; - filled_count++; - } - } - - // Fill int input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (int_input[i] == NULL) - { - temp_int_input[i] = 0; - int_input[i] = &temp_int_input[i]; - filled_count++; - } - } - - // Fill int output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (int_output[i] == NULL) - { - temp_int_output[i] = 0; - int_output[i] = &temp_int_output[i]; - filled_count++; - } - } - - // Fill dint input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (dint_input[i] == NULL) - { - temp_dint_input[i] = 0; - dint_input[i] = &temp_dint_input[i]; - filled_count++; - } - } - - // Fill dint output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (dint_output[i] == NULL) - { - temp_dint_output[i] = 0; - dint_output[i] = &temp_dint_output[i]; - filled_count++; - } - } - - // Fill lint input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (lint_input[i] == NULL) - { - temp_lint_input[i] = 0; - lint_input[i] = &temp_lint_input[i]; - filled_count++; - } - } - - // Fill lint output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (lint_output[i] == NULL) - { - temp_lint_output[i] = 0; - lint_output[i] = &temp_lint_output[i]; - filled_count++; - } - } - - // Fill int memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (int_memory[i] == NULL) - { - temp_int_memory[i] = 0; - int_memory[i] = &temp_int_memory[i]; - filled_count++; - } - } - - // Fill dint memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (dint_memory[i] == NULL) - { - temp_dint_memory[i] = 0; - dint_memory[i] = &temp_dint_memory[i]; - filled_count++; - } - } - - // Fill lint memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - if (lint_memory[i] == NULL) - { - temp_lint_memory[i] = 0; - lint_memory[i] = &temp_lint_memory[i]; - filled_count++; - } - } - - // Fill bool memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - if (bool_memory[i][b] == NULL) - { - temp_bool_memory[i][b] = 0; - bool_memory[i][b] = &temp_bool_memory[i][b]; - filled_count++; - } - } - } - - log_info("Filled %d NULL pointers in image tables with temporary buffers", filled_count); -} - -void image_tables_clear_null_pointers(void) -{ - // Clear all pointers in image tables - // All pointers will be remapped when a new program is loaded via glueVars() - - // Clear boolean input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - bool_input[i][b] = NULL; - } - } - - // Clear boolean output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - bool_output[i][b] = NULL; - } - } - - // Clear byte input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - byte_input[i] = NULL; - } - - // Clear byte output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - byte_output[i] = NULL; - } - - // Clear int input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - int_input[i] = NULL; - } - - // Clear int output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - int_output[i] = NULL; - } - - // Clear dint input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - dint_input[i] = NULL; - } - - // Clear dint output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - dint_output[i] = NULL; - } - - // Clear lint input pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - lint_input[i] = NULL; - } - - // Clear lint output pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - lint_output[i] = NULL; - } - - // Clear int memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - int_memory[i] = NULL; - } - - // Clear dint memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - dint_memory[i] = NULL; - } - - // Clear lint memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - lint_memory[i] = NULL; - } - - // Clear bool memory pointers - for (int i = 0; i < BUFFER_SIZE; i++) - { - for (int b = 0; b < 8; b++) - { - bool_memory[i][b] = NULL; - } - } - - log_info("Cleared all pointers in image tables"); -} diff --git a/core/src/plc_app/image_tables.cpp b/core/src/plc_app/image_tables.cpp new file mode 100644 index 00000000..de419339 --- /dev/null +++ b/core/src/plc_app/image_tables.cpp @@ -0,0 +1,425 @@ +// image_tables.cpp +// +// Resolves the strucpp .so's exported symbols (configuration accessor, +// locks setter, debug PDU helpers) and walks strucpp::locatedVars[] to +// bind image-table buffer pointers. Plugins read/write through the +// buffer pointers directly under the image-tables mutex. + +#include +#include +#include + +#include + +extern "C" { +#include "include/iec_python.h" +} + +// Layout-compatible mirror of the strucpp ABI. The runtime executable +// is built once and walks ConfigurationInstance / LocatedVar through +// these mirrors; the actual strucpp runtime headers ship with the user +// program upload (under core/generated/strucpp_runtime/include/) and +// are consumed only by scripts/compile.sh when building the .so. +#include "../lib/strucpp_abi.hpp" + +#include "image_tables.h" +#include "plcapp_manager.h" +#include "utils/log.h" +#include "utils/utils.h" + +// --------------------------------------------------------------------------- +// Image-table storage +// --------------------------------------------------------------------------- +IEC_BOOL *bool_input[BUFFER_SIZE][8]; +IEC_BOOL *bool_output[BUFFER_SIZE][8]; + +IEC_BYTE *byte_input[BUFFER_SIZE]; +IEC_BYTE *byte_output[BUFFER_SIZE]; + +IEC_UINT *int_input[BUFFER_SIZE]; +IEC_UINT *int_output[BUFFER_SIZE]; + +IEC_UDINT *dint_input[BUFFER_SIZE]; +IEC_UDINT *dint_output[BUFFER_SIZE]; + +IEC_ULINT *lint_input[BUFFER_SIZE]; +IEC_ULINT *lint_output[BUFFER_SIZE]; + +IEC_UINT *int_memory[BUFFER_SIZE]; +IEC_UDINT *dint_memory[BUFFER_SIZE]; +IEC_ULINT *lint_memory[BUFFER_SIZE]; +IEC_BOOL *bool_memory[BUFFER_SIZE][8]; + +// --------------------------------------------------------------------------- +// strucpp shim: per-project located-variable descriptor accessors +// (declared as C-linkage in the .so via runtime_v4_entry.cpp). +// --------------------------------------------------------------------------- +namespace { + using GetLocatedVarsFn = const strucpp::LocatedVar *(*)(void); + using GetLocatedCountFn = uint32_t (*)(void); + + GetLocatedVarsFn ext_strucpp_get_located_vars = nullptr; + GetLocatedCountFn ext_strucpp_get_located_var_count = nullptr; +} + +// --------------------------------------------------------------------------- +// Resolved .so symbols +// --------------------------------------------------------------------------- +void (*ext_strucpp_advance_time)(uint64_t) = nullptr; + +uint8_t (*ext_strucpp_debug_array_count)(void) = nullptr; +uint16_t (*ext_strucpp_debug_elem_count) (uint8_t) = nullptr; +uint16_t (*ext_strucpp_debug_size) (uint8_t, uint16_t) = nullptr; +uint8_t (*ext_strucpp_debug_set) (uint8_t, uint16_t, bool, + const uint8_t *, uint16_t) = nullptr; +uint16_t (*ext_strucpp_debug_read) (uint8_t, uint16_t, uint8_t *) = nullptr; +uint8_t (*ext_strucpp_debug_write) (uint8_t, uint16_t, + const uint8_t *, uint16_t) = nullptr; + +namespace { + using GetConfigFn = strucpp::ConfigurationInstance *(*)(void); + using SetLocksFn = void (*)(pthread_mutex_t *, pthread_mutex_t *); + + GetConfigFn ext_strucpp_get_config = nullptr; + SetLocksFn ext_strucpp_set_locks = nullptr; + + strucpp::ConfigurationInstance *g_config_ptr = nullptr; + + pthread_mutex_t g_image_tables_mutex; + pthread_mutex_t g_global_vars_mutex; + bool g_locks_initialized = false; + + int init_recursive_pi_mutex(pthread_mutex_t *m) + { + pthread_mutexattr_t attr; + if (pthread_mutexattr_init(&attr) != 0) return -1; + pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); + int rc = pthread_mutex_init(m, &attr); + pthread_mutexattr_destroy(&attr); + return rc; + } + + void *resolve(PluginManager *pm, const char *name, bool required) + { + void *sym = plugin_manager_get_symbol(pm, name); + if (!sym && required) + { + log_error("[strucpp] required symbol '%s' missing from .so", name); + } + return sym; + } +} // namespace + +extern "C" pthread_mutex_t *image_tables_mutex(void) +{ + return &g_image_tables_mutex; +} + +extern "C" pthread_mutex_t *global_vars_mutex(void) +{ + return &g_global_vars_mutex; +} + +extern "C" void *strucpp_config_handle(void) +{ + return g_config_ptr; +} + +// Walk the loaded configuration's tasks and store the GCD of declared +// intervals into base_tick_ns. Falls back to the 20 ms default if the +// configuration has no tasks (defensive — symbols_init returns success +// only after g_config_ptr is non-null). +static uint64_t gcd_u64(uint64_t a, uint64_t b) +{ + while (b) + { + uint64_t t = b; + b = a % b; + a = t; + } + return a; +} + +static void compute_base_tick_from_config(strucpp::ConfigurationInstance *cfg) +{ + uint64_t gcd_ns = 0; + auto *resources = cfg->get_resources(); + for (size_t r = 0; r < cfg->get_resource_count(); ++r) + { + for (size_t t = 0; t < resources[r].task_count; ++t) + { + uint64_t ivl = (uint64_t)resources[r].tasks[t].interval_ns; + if (ivl == 0) ivl = 20000000ULL; + gcd_ns = (gcd_ns == 0) ? ivl : gcd_u64(gcd_ns, ivl); + } + } + if (gcd_ns != 0) base_tick_ns = gcd_ns; +} + +extern "C" int symbols_init(PluginManager *pm) +{ + *(void **)&ext_strucpp_advance_time = resolve(pm, "strucpp_advance_time", true); + + *(void **)&ext_strucpp_program_md5 = plugin_manager_get_symbol(pm, "strucpp_program_md5"); + + *(void **)&ext_strucpp_get_config = resolve(pm, "strucpp_get_config", true); + *(void **)&ext_strucpp_set_locks = resolve(pm, "strucpp_set_locks", true); + + *(void **)&ext_strucpp_get_located_vars = resolve(pm, "strucpp_get_located_vars", true); + *(void **)&ext_strucpp_get_located_var_count = resolve(pm, "strucpp_get_located_var_count", true); + + *(void **)&ext_strucpp_debug_array_count = resolve(pm, "strucpp_debug_array_count", true); + *(void **)&ext_strucpp_debug_elem_count = resolve(pm, "strucpp_debug_elem_count", true); + *(void **)&ext_strucpp_debug_size = resolve(pm, "strucpp_debug_size", true); + *(void **)&ext_strucpp_debug_set = resolve(pm, "strucpp_debug_set", true); + *(void **)&ext_strucpp_debug_read = resolve(pm, "strucpp_debug_read", true); + *(void **)&ext_strucpp_debug_write = resolve(pm, "strucpp_debug_write", true); + + if (!ext_strucpp_advance_time || + !ext_strucpp_get_config || !ext_strucpp_set_locks || + !ext_strucpp_get_located_vars || !ext_strucpp_get_located_var_count || + !ext_strucpp_debug_array_count || !ext_strucpp_debug_elem_count || + !ext_strucpp_debug_size || !ext_strucpp_debug_set || + !ext_strucpp_debug_read || !ext_strucpp_debug_write) + { + log_error("[strucpp] failed to resolve all required .so symbols"); + return -1; + } + + if (!g_locks_initialized) + { + if (init_recursive_pi_mutex(&g_image_tables_mutex) != 0 || + init_recursive_pi_mutex(&g_global_vars_mutex) != 0) + { + log_error("[strucpp] failed to initialize resource mutexes"); + return -1; + } + g_locks_initialized = true; + } + + ext_strucpp_set_locks(&g_image_tables_mutex, &g_global_vars_mutex); + + g_config_ptr = ext_strucpp_get_config(); + if (!g_config_ptr) + { + log_error("[strucpp] strucpp_get_config returned NULL"); + return -1; + } + + /* Compute base_tick_ns from the loaded configuration. Replaces the + * old config_init__ shim entry — runtime owns the tick now. */ + compute_base_tick_from_config(g_config_ptr); + + void (*ext_python_loader_set_loggers)(void (*)(const char *, ...), + void (*)(const char *, ...)); + *(void **)&ext_python_loader_set_loggers = + plugin_manager_get_symbol(pm, "python_loader_set_loggers"); + if (ext_python_loader_set_loggers) + { + ext_python_loader_set_loggers(log_info, log_error); + log_info("[python] loader logging callbacks initialized"); + } + + log_info("[strucpp] symbols resolved (config=%p, debug=hier)", + (void *)g_config_ptr); + return 0; +} + +void image_tables_bind_located_vars(void) +{ + if (!ext_strucpp_get_located_vars || !ext_strucpp_get_located_var_count) + { + log_warn("[image_tables] located-vars accessors unresolved — skip"); + return; + } + + const strucpp::LocatedVar *lv_array = ext_strucpp_get_located_vars(); + uint32_t lv_count = ext_strucpp_get_located_var_count(); + uint32_t bound = 0, skipped = 0; + + for (uint32_t i = 0; i < lv_count; ++i) + { + const strucpp::LocatedVar &lv = lv_array[i]; + + if (lv.pointer == nullptr) + { + log_warn("[image_tables] locatedVars[%u] has NULL pointer " + "(area=%u size=%u byte=%u bit=%u)", + i, (unsigned)lv.area, (unsigned)lv.size, + (unsigned)lv.byte_index, (unsigned)lv.bit_index); + ++skipped; + continue; + } + + if (lv.byte_index >= BUFFER_SIZE) + { + log_warn("[image_tables] locatedVars[%u] byte_index %u exceeds " + "BUFFER_SIZE %d — skipping", + i, (unsigned)lv.byte_index, BUFFER_SIZE); + ++skipped; + continue; + } + + // strucpp stores raw_ptr() pointing at IECVar's underlying primitive + // storage (the value_ field), which is layout-compatible with the + // runtime's plain ::IEC_* typedefs in core/src/lib/iec_types.h. + // Cast unconditionally; the layout is guaranteed by IECVar's + // raw_ptr() contract. + switch (lv.area) + { + case strucpp::LocatedArea::Input: + switch (lv.size) + { + case strucpp::LocatedSize::Bit: + if (lv.bit_index < 8) + bool_input[lv.byte_index][lv.bit_index] = (::IEC_BOOL *)lv.pointer; + break; + case strucpp::LocatedSize::Byte: + byte_input[lv.byte_index] = (::IEC_BYTE *)lv.pointer; + break; + case strucpp::LocatedSize::Word: + int_input[lv.byte_index] = (::IEC_UINT *)lv.pointer; + break; + case strucpp::LocatedSize::DWord: + dint_input[lv.byte_index] = (::IEC_UDINT *)lv.pointer; + break; + case strucpp::LocatedSize::LWord: + lint_input[lv.byte_index] = (::IEC_ULINT *)lv.pointer; + break; + } + break; + + case strucpp::LocatedArea::Output: + switch (lv.size) + { + case strucpp::LocatedSize::Bit: + if (lv.bit_index < 8) + bool_output[lv.byte_index][lv.bit_index] = (::IEC_BOOL *)lv.pointer; + break; + case strucpp::LocatedSize::Byte: + byte_output[lv.byte_index] = (::IEC_BYTE *)lv.pointer; + break; + case strucpp::LocatedSize::Word: + int_output[lv.byte_index] = (::IEC_UINT *)lv.pointer; + break; + case strucpp::LocatedSize::DWord: + dint_output[lv.byte_index] = (::IEC_UDINT *)lv.pointer; + break; + case strucpp::LocatedSize::LWord: + lint_output[lv.byte_index] = (::IEC_ULINT *)lv.pointer; + break; + } + break; + + case strucpp::LocatedArea::Memory: + switch (lv.size) + { + case strucpp::LocatedSize::Bit: + if (lv.bit_index < 8) + bool_memory[lv.byte_index][lv.bit_index] = (::IEC_BOOL *)lv.pointer; + break; + case strucpp::LocatedSize::Word: + int_memory[lv.byte_index] = (::IEC_UINT *)lv.pointer; + break; + case strucpp::LocatedSize::DWord: + dint_memory[lv.byte_index] = (::IEC_UDINT *)lv.pointer; + break; + case strucpp::LocatedSize::LWord: + lint_memory[lv.byte_index] = (::IEC_ULINT *)lv.pointer; + break; + default: + ++skipped; + continue; + } + break; + + default: + log_warn("[image_tables] locatedVars[%u] unknown area %u — skipping", + i, (unsigned)lv.area); + ++skipped; + continue; + } + ++bound; + } + + log_info("[image_tables] bound %u located variables (%u skipped of %u)", + bound, skipped, lv_count); +} + +// --------------------------------------------------------------------------- +// Backing storage for slots not covered by located variables. +// --------------------------------------------------------------------------- +static IEC_BOOL temp_bool_input[BUFFER_SIZE][8]; +static IEC_BOOL temp_bool_output[BUFFER_SIZE][8]; +static IEC_BYTE temp_byte_input[BUFFER_SIZE]; +static IEC_BYTE temp_byte_output[BUFFER_SIZE]; +static IEC_UINT temp_int_input[BUFFER_SIZE]; +static IEC_UINT temp_int_output[BUFFER_SIZE]; +static IEC_UDINT temp_dint_input[BUFFER_SIZE]; +static IEC_UDINT temp_dint_output[BUFFER_SIZE]; +static IEC_ULINT temp_lint_input[BUFFER_SIZE]; +static IEC_ULINT temp_lint_output[BUFFER_SIZE]; +static IEC_UINT temp_int_memory[BUFFER_SIZE]; +static IEC_UDINT temp_dint_memory[BUFFER_SIZE]; +static IEC_ULINT temp_lint_memory[BUFFER_SIZE]; +static IEC_BOOL temp_bool_memory[BUFFER_SIZE][8]; + +void image_tables_fill_null_pointers(void) +{ + int filled = 0; + for (int i = 0; i < BUFFER_SIZE; ++i) + { + for (int b = 0; b < 8; ++b) + { + if (!bool_input[i][b]) { temp_bool_input[i][b] = 0; bool_input[i][b] = &temp_bool_input[i][b]; ++filled; } + if (!bool_output[i][b]) { temp_bool_output[i][b] = 0; bool_output[i][b] = &temp_bool_output[i][b]; ++filled; } + if (!bool_memory[i][b]) { temp_bool_memory[i][b] = 0; bool_memory[i][b] = &temp_bool_memory[i][b]; ++filled; } + } + if (!byte_input[i]) { temp_byte_input[i] = 0; byte_input[i] = &temp_byte_input[i]; ++filled; } + if (!byte_output[i]) { temp_byte_output[i] = 0; byte_output[i] = &temp_byte_output[i]; ++filled; } + if (!int_input[i]) { temp_int_input[i] = 0; int_input[i] = &temp_int_input[i]; ++filled; } + if (!int_output[i]) { temp_int_output[i] = 0; int_output[i] = &temp_int_output[i]; ++filled; } + if (!dint_input[i]) { temp_dint_input[i] = 0; dint_input[i] = &temp_dint_input[i]; ++filled; } + if (!dint_output[i]) { temp_dint_output[i] = 0; dint_output[i] = &temp_dint_output[i]; ++filled; } + if (!lint_input[i]) { temp_lint_input[i] = 0; lint_input[i] = &temp_lint_input[i]; ++filled; } + if (!lint_output[i]) { temp_lint_output[i] = 0; lint_output[i] = &temp_lint_output[i]; ++filled; } + if (!int_memory[i]) { temp_int_memory[i] = 0; int_memory[i] = &temp_int_memory[i]; ++filled; } + if (!dint_memory[i]) { temp_dint_memory[i] = 0; dint_memory[i] = &temp_dint_memory[i]; ++filled; } + if (!lint_memory[i]) { temp_lint_memory[i] = 0; lint_memory[i] = &temp_lint_memory[i]; ++filled; } + } + log_info("[image_tables] filled %d NULL slots with backing buffers", filled); +} + +void image_tables_clear_null_pointers(void) +{ + std::memset(bool_input, 0, sizeof(bool_input)); + std::memset(bool_output, 0, sizeof(bool_output)); + std::memset(byte_input, 0, sizeof(byte_input)); + std::memset(byte_output, 0, sizeof(byte_output)); + std::memset(int_input, 0, sizeof(int_input)); + std::memset(int_output, 0, sizeof(int_output)); + std::memset(dint_input, 0, sizeof(dint_input)); + std::memset(dint_output, 0, sizeof(dint_output)); + std::memset(lint_input, 0, sizeof(lint_input)); + std::memset(lint_output, 0, sizeof(lint_output)); + std::memset(int_memory, 0, sizeof(int_memory)); + std::memset(dint_memory, 0, sizeof(dint_memory)); + std::memset(lint_memory, 0, sizeof(lint_memory)); + std::memset(bool_memory, 0, sizeof(bool_memory)); + + ext_strucpp_advance_time = nullptr; + ext_strucpp_program_md5 = nullptr; + ext_strucpp_get_config = nullptr; + ext_strucpp_set_locks = nullptr; + ext_strucpp_debug_array_count = nullptr; + ext_strucpp_debug_elem_count = nullptr; + ext_strucpp_debug_size = nullptr; + ext_strucpp_debug_set = nullptr; + ext_strucpp_debug_read = nullptr; + ext_strucpp_get_located_vars = nullptr; + ext_strucpp_get_located_var_count = nullptr; + g_config_ptr = nullptr; + + log_info("[image_tables] cleared all pointers"); +} diff --git a/core/src/plc_app/image_tables.h b/core/src/plc_app/image_tables.h index 6d079874..0013d43e 100644 --- a/core/src/plc_app/image_tables.h +++ b/core/src/plc_app/image_tables.h @@ -1,128 +1,134 @@ #ifndef IMAGE_TABLES_H #define IMAGE_TABLES_H +#include +#include +#include + #include "../lib/iec_types.h" #include "plcapp_manager.h" +#ifdef __cplusplus +extern "C" +{ +#endif + #define BUFFER_SIZE 1024 #define libplc_build_dir "./build" -// Internal buffers for I/O and memory. -// Booleans -extern IEC_BOOL *bool_input[BUFFER_SIZE][8]; -extern IEC_BOOL *bool_output[BUFFER_SIZE][8]; - -// Bytes -extern IEC_BYTE *byte_input[BUFFER_SIZE]; -extern IEC_BYTE *byte_output[BUFFER_SIZE]; - -// Analog I/O -extern IEC_UINT *int_input[BUFFER_SIZE]; -extern IEC_UINT *int_output[BUFFER_SIZE]; - -// 32bit I/O -extern IEC_UDINT *dint_input[BUFFER_SIZE]; -extern IEC_UDINT *dint_output[BUFFER_SIZE]; - -// 64bit I/O -extern IEC_ULINT *lint_input[BUFFER_SIZE]; -extern IEC_ULINT *lint_output[BUFFER_SIZE]; - -// Memory -extern IEC_UINT *int_memory[BUFFER_SIZE]; -extern IEC_UDINT *dint_memory[BUFFER_SIZE]; -extern IEC_ULINT *lint_memory[BUFFER_SIZE]; -extern IEC_BOOL *bool_memory[BUFFER_SIZE][8]; - -/** - * @brief Set the buffer pointers for the plugin manager - * - * @param[in] IEC The IEC data types - */ -extern void (*ext_setBufferPointers)( - IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], - IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], - IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], - IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], - IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], - IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], - IEC_ULINT *lint_memory[BUFFER_SIZE]); - -/** - * @brief Set the buffer pointers for the plugin manager (v4 with bool_memory) - * - * This version includes bool_memory support for %MX locations. - * Only present in programs compiled with -DOPENPLC_V4. - */ -extern void (*ext_setBufferPointers_v4)( - IEC_BOOL *input_bool[BUFFER_SIZE][8], IEC_BOOL *output_bool[BUFFER_SIZE][8], - IEC_BYTE *input_byte[BUFFER_SIZE], IEC_BYTE *output_byte[BUFFER_SIZE], - IEC_UINT *input_int[BUFFER_SIZE], IEC_UINT *output_int[BUFFER_SIZE], - IEC_UDINT *input_dint[BUFFER_SIZE], IEC_UDINT *output_dint[BUFFER_SIZE], - IEC_ULINT *input_lint[BUFFER_SIZE], IEC_ULINT *output_lint[BUFFER_SIZE], - IEC_UINT *int_memory[BUFFER_SIZE], IEC_UDINT *dint_memory[BUFFER_SIZE], - IEC_ULINT *lint_memory[BUFFER_SIZE], IEC_BOOL *memory_bool[BUFFER_SIZE][8]); - -/** - * @brief Common ticktime variable from the PLC program - * - * @param[in] tick The current tick value - */ -extern void (*ext_config_run__)(unsigned long tick); - -/** - * @brief Initialize the configuration - */ -extern void (*ext_config_init__)(void); - -/** - * @brief Glue variables together - */ -extern void (*ext_glueVars)(void); -extern void (*ext_updateTime)(void); - -/** - * @brief Debug functions - */ -extern void (*ext_set_endianness)(uint8_t value); -extern uint16_t (*ext_get_var_count)(void); -extern size_t (*ext_get_var_size)(size_t idx); -extern void *(*ext_get_var_addr)(size_t idx); -extern void (*ext_set_trace)(size_t idx, bool forced, void *val); - -/** - * @brief Initialize symbols for the plugin manager - * - * @param[in] pm The plugin manager to initialize symbols for - * @return 0 on success, -1 on failure - */ -int symbols_init(PluginManager *pm); - -/** - * @brief Fill NULL pointers in image tables with temporary backing buffers - * - * This function iterates through all image table arrays and points any NULL - * entries to temporary backing storage. This ensures that plugins accessing - * addresses not used by the PLC program won't fail due to NULL pointer access. - * - * Must be called after ext_glueVars() has mapped the user program's located - * variables to the image tables. - * - * @note This function should be called with buffer_mutex held for thread safety. - */ -void image_tables_fill_null_pointers(void); - -/** - * @brief Clear all pointers from image tables - * - * This function resets all pointers in the image tables back to NULL. - * All pointers will be remapped when a new program is loaded via glueVars(). - * - * Must be called before unloading a PLC program to ensure clean state for - * the next program load. - * - * @note This function should be called with buffer_mutex held for thread safety. - */ -void image_tables_clear_null_pointers(void); - -#endif // IMAGE_TABLES_H + /* ------------------------------------------------------------------------- + * Image-table buffers (booleans, bytes, ints, dints, lints, memories). + * + * Populated at program-load time by image_tables_bind_located_vars(), + * which walks strucpp::locatedVars[] and points each slot at the + * matching IECVar's underlying primitive storage. Plugins read/write + * these directly under the image-tables mutex. + * --------------------------------------------------------------------- */ + + extern IEC_BOOL *bool_input[BUFFER_SIZE][8]; + extern IEC_BOOL *bool_output[BUFFER_SIZE][8]; + + extern IEC_BYTE *byte_input[BUFFER_SIZE]; + extern IEC_BYTE *byte_output[BUFFER_SIZE]; + + extern IEC_UINT *int_input[BUFFER_SIZE]; + extern IEC_UINT *int_output[BUFFER_SIZE]; + + extern IEC_UDINT *dint_input[BUFFER_SIZE]; + extern IEC_UDINT *dint_output[BUFFER_SIZE]; + + extern IEC_ULINT *lint_input[BUFFER_SIZE]; + extern IEC_ULINT *lint_output[BUFFER_SIZE]; + + extern IEC_UINT *int_memory[BUFFER_SIZE]; + extern IEC_UDINT *dint_memory[BUFFER_SIZE]; + extern IEC_ULINT *lint_memory[BUFFER_SIZE]; + extern IEC_BOOL *bool_memory[BUFFER_SIZE][8]; + + /* ------------------------------------------------------------------------- + * Resolved .so symbols (populated by symbols_init). + * + * strucpp_advance_time is called once per scan cycle by + * plc_run_io_cycle_post; it bumps the per-.so __CURRENT_TIME_NS by the + * runtime-supplied tick. base_tick_ns is owned runtime-side (utils.c) + * and computed in symbols_init by walking the loaded configuration. + * --------------------------------------------------------------------- */ + + extern void (*ext_strucpp_advance_time)(uint64_t tick_ns); + + /* Hierarchical debug PDU shims (defined inside the .so by + * debug_dispatch.hpp under STRUCPP_V4_DEBUG_EXPORTS_DEFINE). */ + extern uint8_t (*ext_strucpp_debug_array_count)(void); + extern uint16_t (*ext_strucpp_debug_elem_count) (uint8_t arr); + extern uint16_t (*ext_strucpp_debug_size) (uint8_t arr, uint16_t elem); + extern uint8_t (*ext_strucpp_debug_set) (uint8_t arr, uint16_t elem, + bool forcing, + const uint8_t *bytes, + uint16_t len); + extern uint16_t (*ext_strucpp_debug_read) (uint8_t arr, uint16_t elem, + uint8_t *dest); + /* Soft write — updates the variable's underlying value via + * IECVar::set(). If the variable is currently forced, the write is + * silently ignored (force remains authoritative). Distinct from + * ext_strucpp_debug_set(forcing=true) which pins the value + * indefinitely. Used by plugins (OPC-UA, BACnet) that want regular + * write semantics rather than debugger-style forcing. */ + extern uint8_t (*ext_strucpp_debug_write) (uint8_t arr, uint16_t elem, + const uint8_t *bytes, + uint16_t len); + + /* ------------------------------------------------------------------------- + * Symbol resolution. + * + * Resolves all required entry points from the dlopen'd .so, including + * the strucpp shim entries (strucpp_get_config / strucpp_set_locks) + * the runtime needs to walk the configuration and plumb mutexes. + * Initializes runtime-owned image-tables and globals mutexes (recursive + * PI) on first call and hands their pointers to the .so. + * + * Returns 0 on success, -1 if anything required is missing. + * --------------------------------------------------------------------- */ + int symbols_init(PluginManager *pm); + + /* ------------------------------------------------------------------------- + * Walk strucpp::locatedVars[] and point each image-table slot at the + * corresponding IECVar's underlying primitive storage. Caller must hold + * the image-tables mutex. + * --------------------------------------------------------------------- */ + void image_tables_bind_located_vars(void); + + /* ------------------------------------------------------------------------- + * After binding, fill any unbound image-table slots with private + * backing buffers so plugins reading those addresses don't dereference + * NULL. Caller must hold the image-tables mutex. + * --------------------------------------------------------------------- */ + void image_tables_fill_null_pointers(void); + + /* ------------------------------------------------------------------------- + * Reset all image-table pointers to NULL before unloading a program. + * Caller must hold the image-tables mutex. + * --------------------------------------------------------------------- */ + void image_tables_clear_null_pointers(void); + + /* ------------------------------------------------------------------------- + * Resource mutex accessors. Returns pointers to the runtime-owned + * recursive PI mutexes that protect the image tables and the globals. + * Plugins / the runtime housekeeping use these directly; the codegen + * lock guards inside the .so lock the same instances via the pointer + * stash plumbed through strucpp_set_locks(). + * --------------------------------------------------------------------- */ + pthread_mutex_t *image_tables_mutex(void); + pthread_mutex_t *global_vars_mutex(void); + + /* ------------------------------------------------------------------------- + * Returns the cached strucpp::ConfigurationInstance* (as void* — the + * runtime's .cpp callers static_cast to the right type). NULL until + * symbols_init() succeeds; reset to NULL on image_tables_clear_null_pointers(). + * --------------------------------------------------------------------- */ + void *strucpp_config_handle(void); + +#ifdef __cplusplus +} +#endif + +#endif /* IMAGE_TABLES_H */ diff --git a/core/src/plc_app/plc_io_cycle.cpp b/core/src/plc_app/plc_io_cycle.cpp new file mode 100644 index 00000000..8c25f7cc --- /dev/null +++ b/core/src/plc_app/plc_io_cycle.cpp @@ -0,0 +1,34 @@ +// plc_io_cycle.cpp — per-cycle I/O work, split into pre/post halves +// around the fastest IEC task's body. +// +// Decoupled from plc_state_manager.cpp so the housekeeping is in one +// place. Both halves run inside the image-tables critical section. + +#include +#include + +extern "C" { +#include "../drivers/plugin_driver.h" +} + +#include "image_tables.h" +#include "journal_buffer.h" +#include "plc_io_cycle.h" +#include "utils/utils.h" + +extern std::atomic plc_heartbeat; +extern plugin_driver_t *plugin_driver; + +extern "C" void plc_run_io_cycle_pre(void) +{ + journal_apply_and_clear(); + if (plugin_driver) plugin_driver_cycle_start(plugin_driver); +} + +extern "C" void plc_run_io_cycle_post(void) +{ + if (ext_strucpp_advance_time) ext_strucpp_advance_time(base_tick_ns); + if (plugin_driver) plugin_driver_cycle_end(plugin_driver); + plc_heartbeat.store((long)time(nullptr)); + ++scan_counter; +} diff --git a/core/src/plc_app/plc_io_cycle.h b/core/src/plc_app/plc_io_cycle.h new file mode 100644 index 00000000..42782ba3 --- /dev/null +++ b/core/src/plc_app/plc_io_cycle.h @@ -0,0 +1,36 @@ +#ifndef OPENPLC_PLC_IO_CYCLE_H +#define OPENPLC_PLC_IO_CYCLE_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * I/O cycle helpers. + * + * Encapsulates the work that has to happen once per scan around the + * highest-priority task's body: + * + * plc_run_io_cycle_pre() — runs BEFORE the task body + * drain journal, fire plugin cycle_start + * + * plc_run_io_cycle_post() — runs AFTER the task body + * advance __CURRENT_TIME, fire plugin cycle_end, update heartbeat, + * increment scan_counter + * + * Both are called inside the image-tables critical section. The + * fastest task's thread (Phase 6 picks it; ctx->is_fastest_task) calls + * these around its body. Other task threads just run their bodies + * without housekeeping. + * + * See docs/strucpp-migration/07-runtime-v4-plugin-and-io.md for the + * topology rationale. + */ +void plc_run_io_cycle_pre(void); +void plc_run_io_cycle_post(void); + +#ifdef __cplusplus +} +#endif + +#endif /* OPENPLC_PLC_IO_CYCLE_H */ diff --git a/core/src/plc_app/plc_main.c b/core/src/plc_app/plc_main.c index 1465a7ec..bc9b160e 100644 --- a/core/src/plc_app/plc_main.c +++ b/core/src/plc_app/plc_main.c @@ -16,7 +16,6 @@ #include "image_tables.h" #include "plc_state_manager.h" #include "plcapp_manager.h" -#include "scan_cycle_manager.h" #include "unix_socket.h" #include "utils/log.h" #include "utils/utils.h" @@ -33,6 +32,16 @@ void handle_sigint(int sig) keep_running = 0; } +/* Process-wide no-op SIGUSR1 handler. The wake mechanism is EINTR + * delivery to a specific thread via pthread_kill(target, SIGUSR1) — the + * handler body itself does nothing. Installed exactly once at startup + * (instead of being re-installed by every thread that wants to be + * woken) so that handlers can never clobber each other. */ +static void handle_sigusr1(int sig) +{ + (void)sig; +} + int main(int argc, char *argv[]) { bool print_debug = false; @@ -80,6 +89,20 @@ int main(int argc, char *argv[]) sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); + // Install the process-wide SIGUSR1 wake handler exactly once. Task + // threads (plc_state_manager.cpp) and EtherCAT bus threads + // (ethercat_plugin.c) both rely on EINTR-from-pthread_kill to break + // out of clock_nanosleep on stop. Previously each of those callers + // re-installed sigaction(SIGUSR1, …) on its own — last writer wins, + // and any future divergence between handlers (e.g. one logs, the + // other resets state) would silently lose half the time depending + // on thread spawn order. One install, here, eliminates the race. + struct sigaction wake_sa; + wake_sa.sa_handler = handle_sigusr1; + sigemptyset(&wake_sa.sa_mask); + wake_sa.sa_flags = 0; + sigaction(SIGUSR1, &wake_sa, NULL); + // Make sure PLC starts in STOP state plc_set_state(PLC_STATE_STOPPED); diff --git a/core/src/plc_app/plc_state_manager.c b/core/src/plc_app/plc_state_manager.c deleted file mode 100644 index b701812d..00000000 --- a/core/src/plc_app/plc_state_manager.c +++ /dev/null @@ -1,450 +0,0 @@ -#include -#include -#include -#include -#include - -#include "../drivers/plugin_driver.h" -#include "image_tables.h" -#include "journal_buffer.h" -#include "plc_state_manager.h" -#include "plcapp_manager.h" -#include "scan_cycle_manager.h" -#include "utils/log.h" -#include "utils/utils.h" - -static PLCState plc_state = PLC_STATE_STOPPED; -static pthread_mutex_t state_mutex = PTHREAD_MUTEX_INITIALIZER; - -struct timespec timer_start; -pthread_t plc_thread; -PluginManager *plc_program = NULL; - -extern plc_timing_stats_t plc_timing_stats; -extern atomic_long plc_heartbeat; -extern plugin_driver_t *plugin_driver; - -// Signal recovery for PLC cycle thread crashes (SIGFPE, SIGSEGV) -static sigjmp_buf plc_crash_jmp; -static pthread_t plc_thread_id; -static volatile sig_atomic_t plc_crash_signal = 0; -static volatile sig_atomic_t holding_buffer_mutex = 0; - -// NOTE on siglongjmp safety: siglongjmp from a hardware-raised signal is -// well-defined when process state (heap/stack) is intact at the time of the -// signal. For generated PLC code this is the expected case because: -// - SIGFPE: the faulting instruction is trapped before completing, no -// memory is modified, so recovery is always safe. -// - SIGSEGV: typically caused by out-of-bounds access into an unmapped -// page. The write/read is trapped by the MMU before completing, so -// process state is clean. -// The theoretical risk is a SIGSEGV caused by a prior silent corruption -// (write to a valid but wrong address) leaving heap/stack inconsistent. -// This is extremely unlikely with generated PLC code (no heap allocation, -// no recursion). If it does happen, the recovered process will crash again -// shortly and Layer 3 (webserver safe mode) will catch the rapid crash -// pattern and restart without loading the faulty program. -static void plc_crash_handler(int sig) -{ - // Only handle if the crash came from the PLC cycle thread - if (!pthread_equal(pthread_self(), plc_thread_id)) - { - // Not our thread - restore default handler and re-raise - signal(sig, SIG_DFL); - raise(sig); - return; - } - - plc_crash_signal = sig; - siglongjmp(plc_crash_jmp, sig); -} - -void *plc_cycle_thread(void *arg) -{ - PluginManager *pm = (PluginManager *)arg; - - // Record this thread's ID for the crash handler - plc_thread_id = pthread_self(); - plc_crash_signal = 0; - - // Initialize scan cycle manager (priority-inheriting stats mutex) - if (scan_cycle_manager_init() != 0) - { - log_error("Failed to initialize scan cycle manager"); - } - - // mlockall is process-wide; safe to call before plugin init. - lock_memory(); - - symbols_init(pm); - ext_config_init__(); - ext_glueVars(); - - // Fill NULL pointers in image tables with temporary buffers - // This ensures plugins can access addresses not used by the PLC program - plugin_mutex_take(&plugin_driver->buffer_mutex); - image_tables_fill_null_pointers(); - plugin_mutex_give(&plugin_driver->buffer_mutex); - - // Initialize journal buffer for race-condition-free plugin writes - journal_buffer_ptrs_t journal_ptrs = { - .bool_input = bool_input, - .bool_output = bool_output, - .bool_memory = bool_memory, - .byte_input = byte_input, - .byte_output = byte_output, - .int_input = int_input, - .int_output = int_output, - .int_memory = int_memory, - .dint_input = dint_input, - .dint_output = dint_output, - .dint_memory = dint_memory, - .lint_input = lint_input, - .lint_output = lint_output, - .lint_memory = lint_memory, - .buffer_size = BUFFER_SIZE, - .image_mutex = &plugin_driver->buffer_mutex - }; - if (journal_init(&journal_ptrs) != 0) { - log_error("Failed to initialize journal buffer"); - } else { - log_info("Journal buffer initialized"); - } - - // Start enabled plugins now that image tables are populated. - // This is the earliest safe point: ext_glueVars() + image_tables_fill_null_pointers() - // have run, so plugins will not encounter NULL buffer pointers. - if (plugin_driver) - { - plugin_driver_start(plugin_driver); - log_info("[PLUGIN]: Enabled plugins started"); - } - - // Elevate AFTER plugins start: pthread default is PTHREAD_INHERIT_SCHED, - // so any thread spawned during plugin_driver_start would otherwise - // inherit FIFO and contend with the scan on its core. - set_realtime_priority(); - - // Install signal handlers for crash recovery BEFORE entering the main loop. - // This allows SIGFPE (e.g. division by zero) and SIGSEGV (e.g. bad array - // access) in the user's PLC program to be caught and recovered from, - // instead of killing the entire runtime process. - struct sigaction crash_sa; - memset(&crash_sa, 0, sizeof(crash_sa)); - crash_sa.sa_handler = plc_crash_handler; - sigemptyset(&crash_sa.sa_mask); - // SA_NODEFER: allow the handler to catch the same signal again after - // siglongjmp returns (needed because the signal is still blocked otherwise) - crash_sa.sa_flags = SA_NODEFER; - sigaction(SIGFPE, &crash_sa, NULL); - sigaction(SIGSEGV, &crash_sa, NULL); - - log_info("Starting main loop"); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_RUNNING; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: RUNNING"); - - plc_timing_stats.scan_count = 0; - - // Get the start time for the running program - clock_gettime(CLOCK_MONOTONIC, &timer_start); - - // Set up the crash recovery point. sigsetjmp returns 0 on initial call, - // and returns the signal number when siglongjmp jumps back here after - // a crash in the PLC program. - int crash_sig = sigsetjmp(plc_crash_jmp, 1); - if (crash_sig != 0) - { - // We got here via siglongjmp from the crash handler. - // Only release the buffer mutex if we held it when we crashed. - if (holding_buffer_mutex) - { - holding_buffer_mutex = 0; - plugin_mutex_give(&plugin_driver->buffer_mutex); - } - - const char *sig_name = (crash_sig == SIGFPE) ? "SIGFPE (arithmetic error, e.g. division by zero)" - : "SIGSEGV (memory access violation)"; - log_error("PLC program crashed with signal %d: %s", crash_sig, sig_name); - log_error("The loaded PLC program contains a fatal error. " - "Upload a corrected program to recover."); - - // Restore default handlers so crashes outside the PLC thread - // still terminate the process as expected - signal(SIGFPE, SIG_DFL); - signal(SIGSEGV, SIG_DFL); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_ERROR; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: ERROR"); - - return NULL; - } - - while (plc_state == PLC_STATE_RUNNING) - { - scan_cycle_time_start(); - holding_buffer_mutex = 1; - plugin_mutex_take(&plugin_driver->buffer_mutex); - - // Apply pending journal entries before plugin hooks run - // This ensures all plugin writes from the previous cycle are visible - journal_apply_and_clear(); - - // Call cycle_start for all active native plugins that registered the hook - plugin_driver_cycle_start(plugin_driver); - - // Execute the PLC cycle - ext_config_run__(tick__++); - ext_updateTime(); - - // Call cycle_end for all active native plugins that registered the hook - plugin_driver_cycle_end(plugin_driver); - - // Update Watchdog Heartbeat - atomic_store(&plc_heartbeat, time(NULL)); - - plugin_mutex_give(&plugin_driver->buffer_mutex); - holding_buffer_mutex = 0; - scan_cycle_time_end(); - - // Calculate next start time - timer_start.tv_nsec += *ext_common_ticktime__; - normalize_timespec(&timer_start); - - // Sleep until the next cycle should start - sleep_until(&timer_start); - } - - // Restore default signal handlers when exiting normally - signal(SIGFPE, SIG_DFL); - signal(SIGSEGV, SIG_DFL); - - scan_cycle_manager_cleanup(); - - return NULL; -} - -int load_plc_program(PluginManager *pm) -{ - if (pm == NULL) - { - log_error("Failed to load PLC Program: PluginManager is NULL"); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_ERROR; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: ERROR"); - - return -1; - } - - if (plugin_manager_load(pm)) - { - log_info("Loading PLC application"); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_INIT; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: INIT"); - - // Re-initialize plugins with updated config (e.g. after program re-upload). - // Do NOT start plugins here -- they are started later in plc_cycle_thread() - // after image tables are populated, ensuring plugins never see NULL buffers. - if (plugin_driver) - { - if (plugin_driver_update_config(plugin_driver, "./plugins.conf") == 0) - { - plugin_driver_init(plugin_driver); - log_info("[PLUGIN]: Plugins re-initialized with updated config"); - } - else - { - log_error("[PLUGIN]: Failed to load plugin configuration"); - } - } - - if (pthread_create(&plc_thread, NULL, plc_cycle_thread, pm) != 0) - { - log_error("Failed to create PLC cycle thread"); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_ERROR; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: ERROR"); - - return -1; - } - - return 0; - } - else - { - log_error("Failed to load PLC application"); - - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_EMPTY; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: EMPTY"); - - return -1; - } -} - -int unload_plc_program(PluginManager *pm) -{ - if (pm && pm == plc_program) - { - // Check if we are coming from ERROR state (crash recovery). - // In that case, the PLC thread has already exited via the signal - // handler, so we only need to join it without changing state first. - PLCState prev_state = plc_get_state(); - - if (prev_state != PLC_STATE_ERROR) - { - // Normal shutdown: signal the PLC thread to stop - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_STOPPED; - pthread_mutex_unlock(&state_mutex); - } - - // Wait for the PLC thread to finish - pthread_join(plc_thread, NULL); - - // Cleanup journal buffer before clearing image tables - journal_cleanup(); - log_info("Journal buffer cleaned up"); - - // Stop plugins FIRST (before acquiring mutex) to prevent deadlock - // The S7Comm plugin's RWArea callback acquires buffer_mutex during - // client read operations. If we try to acquire the mutex before - // stopping the plugin, we can deadlock if a client is connected. - plugin_driver_stop(plugin_driver); - - // Clear temporary pointers from image tables before unloading - // This ensures clean state for the next program load - plugin_mutex_take(&plugin_driver->buffer_mutex); - image_tables_clear_null_pointers(); - plugin_mutex_give(&plugin_driver->buffer_mutex); - - // Cleanup Python function blocks BEFORE unloading the shared library - // This terminates Python subprocesses and joins runner threads to prevent - // crash when dlclose() unmaps the code while threads are still running - void (*python_cleanup)(void); - *(void **)(&python_cleanup) = - plugin_manager_get_symbol(pm, "python_blocks_cleanup"); - if (python_cleanup) - { - python_cleanup(); - } - - // Destroy the plugin manager - plugin_manager_destroy(pm); - plc_program = NULL; - - log_info("PLC program unloaded successfully"); - - log_info("PLC State: STOPPED"); - return 0; - } - else - { - log_error("No PLC program loaded or mismatched plugin manager"); - return -1; - } -} - -PLCState plc_get_state(void) -{ - PLCState state; - pthread_mutex_lock(&state_mutex); - state = plc_state; - pthread_mutex_unlock(&state_mutex); - return state; -} - -bool plc_set_state(PLCState new_state) -{ - pthread_mutex_lock(&state_mutex); - if (plc_state == new_state) - { - pthread_mutex_unlock(&state_mutex); - return false; - } - plc_state = new_state; - pthread_mutex_unlock(&state_mutex); - - // Handle transition to running - if (new_state == PLC_STATE_RUNNING) - { - if (plc_program == NULL) - { - char *libplc_path = find_libplc_file(libplc_build_dir); - if (libplc_path == NULL) - { - log_error("Failed to find libplc file"); - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_EMPTY; - pthread_mutex_unlock(&state_mutex); - return false; - } - - plc_program = plugin_manager_create(libplc_path); - free(libplc_path); - - if (plc_program == NULL) - { - log_error("Failed to create PluginManager"); - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_EMPTY; - pthread_mutex_unlock(&state_mutex); - return false; - } - } - if (load_plc_program(plc_program) < 0) - { - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_ERROR; - pthread_mutex_unlock(&state_mutex); - return false; - } - } - - // Handle transition to stopped - else if (new_state == PLC_STATE_STOPPED) - { - if (plc_program) - { - if (unload_plc_program(plc_program) < 0) - { - return false; - } - } - } - - return true; -} - -void plc_state_manager_cleanup(void) -{ - if (plc_program) - { - unload_plc_program(plc_program); - } -} - -void plc_force_error_state(void) -{ - pthread_mutex_lock(&state_mutex); - plc_state = PLC_STATE_ERROR; - pthread_mutex_unlock(&state_mutex); - log_info("PLC State: ERROR"); -} - -int plc_get_crash_signal(void) -{ - return (int)plc_crash_signal; -} diff --git a/core/src/plc_app/plc_state_manager.cpp b/core/src/plc_app/plc_state_manager.cpp new file mode 100644 index 00000000..fce8eb3f --- /dev/null +++ b/core/src/plc_app/plc_state_manager.cpp @@ -0,0 +1,798 @@ +// plc_state_manager.cpp +// +// Walks the loaded program's ConfigurationInstance via virtual dispatch +// (Phase 5), spawns one SCHED_FIFO pthread per IEC TASK (Phase 6), and +// anchors the per-cycle housekeeping window on the fastest task's +// thread (Phase 7). +// +// Linux-only (the runtime targets Linux). + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +extern "C" { +#include "../drivers/plugin_driver.h" +} + +// Runtime-side strucpp ABI mirror — see core/src/lib/strucpp_abi.hpp +#include "../lib/strucpp_abi.hpp" + +#include "image_tables.h" +#include "journal_buffer.h" +#include "plc_io_cycle.h" +#include "plc_state_manager.h" +#include "plcapp_manager.h" +#include "scan_cycle_manager.h" +#include "utils/log.h" +#include "utils/utils.h" + +static PLCState plc_state = PLC_STATE_STOPPED; +static pthread_mutex_t state_mutex = PTHREAD_MUTEX_INITIALIZER; + +struct timespec timer_start; +pthread_t plc_thread; +PluginManager *plc_program = NULL; + +extern std::atomic plc_heartbeat; +extern plugin_driver_t *plugin_driver; + +/* ----------------------------------------------------------------------- + * Per-task storage. Allocated when a program loads, freed on stop. + * + * plc_tasks_lock serialises lifecycle (alloc/publish/free) against + * readers (STATS handler in scan_cycle_manager). See plc_state_manager.h + * for the contract. Read-only once published until the next STOP, so the + * lock is held only briefly on the writer side and for one iteration on + * the reader side. Not a recursive lock — callers must not nest. + * --------------------------------------------------------------------- */ +PlcTaskCtx *plc_tasks = nullptr; +size_t plc_task_count = 0; + +static pthread_mutex_t plc_tasks_lock = PTHREAD_MUTEX_INITIALIZER; + +extern "C" void plc_tasks_reader_lock(void) +{ + pthread_mutex_lock(&plc_tasks_lock); +} + +extern "C" void plc_tasks_reader_unlock(void) +{ + pthread_mutex_unlock(&plc_tasks_lock); +} + +/* The bootstrap thread doesn't run any IEC task body — it does setup, + * spawns task threads, waits, and joins. We still want crash recovery + * on it via a separate jmp pair. The active task's ctx is in __thread + * storage so the signal handler knows which siglongjmp target to use. */ +static __thread PlcTaskCtx *current_task_ctx = nullptr; +static sigjmp_buf bootstrap_crash_jmp; +static volatile sig_atomic_t bootstrap_crash_sig = 0; +static volatile sig_atomic_t bootstrap_holding_mutex = 0; +static volatile sig_atomic_t plc_crash_signal = 0; + +/* The SIGUSR1 wake handler is installed once at process init in + * plc_main.c (handle_sigusr1). Every task thread relies on EINTR from + * pthread_kill(target, SIGUSR1) to break out of clock_nanosleep on + * stop — the handler body itself is a no-op. */ + +static void plc_crash_handler(int sig) +{ + if (current_task_ctx) + { + current_task_ctx->crash_sig = sig; + plc_crash_signal = sig; + siglongjmp(current_task_ctx->crash_jmp, sig); + } + if (pthread_equal(pthread_self(), plc_thread)) + { + bootstrap_crash_sig = sig; + plc_crash_signal = sig; + siglongjmp(bootstrap_crash_jmp, sig); + } + /* Unknown thread — restore default and re-raise so we don't + * silently eat fatal signals from webserver / plugin threads. */ + signal(sig, SIG_DFL); + raise(sig); +} + +/* ----------------------------------------------------------------------- + * Per-task thread function. + * + * Phase 6 keeps this minimal: SCHED_FIFO priority elevation, optional + * CPU affinity, per-thread crash recovery, then a clock_nanosleep loop + * that calls task->programs[]->run() under the image-tables lock. + * Phase 7 specializes the fastest task by adding housekeeping pre/post. + * --------------------------------------------------------------------- */ +static void *plc_task_thread(void *arg) +{ + PlcTaskCtx *ctx = static_cast(arg); + current_task_ctx = ctx; + + pthread_setname_np(pthread_self(), ctx->name); + + int rt = ctx->priority; + if (rt < 1) rt = 1; + if (rt > 99) rt = 99; + sched_param sp{}; + sp.sched_priority = rt; + if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp) != 0) + { + log_warn("[task %s] SCHED_FIFO(%d) failed: %s — running default scheduling", + ctx->name, rt, strerror(errno)); + } + else + { + log_info("[task %s] SCHED_FIFO priority %d", ctx->name, rt); + } + + if (ctx->cpu_affinity_mask != 0) + { + cpu_set_t cs; + CPU_ZERO(&cs); + for (int cpu = 0; cpu < 64 && cpu < CPU_SETSIZE; ++cpu) + { + if (ctx->cpu_affinity_mask & (1ULL << cpu)) CPU_SET(cpu, &cs); + } + if (pthread_setaffinity_np(pthread_self(), sizeof cs, &cs) != 0) + { + log_warn("[task %s] pthread_setaffinity_np failed: %s", + ctx->name, strerror(errno)); + } + } + + if (sigsetjmp(ctx->crash_jmp, 1) != 0) + { + if (ctx->holding_mutex) + { + ctx->holding_mutex = 0; + pthread_mutex_unlock(image_tables_mutex()); + } + log_error("[task %s] crashed (signal %d) — entering ERROR state", + ctx->name, ctx->crash_sig); + plc_force_error_state(); + return nullptr; + } + + auto *task = static_cast(ctx->task_handle); + + timespec next_wakeup; + clock_gettime(CLOCK_MONOTONIC, &next_wakeup); + + while (plc_get_state() == PLC_STATE_RUNNING) + { + /* Set the holding flag AFTER acquiring the mutex. If a fatal signal + * lands between the flag set and the lock returning, the crash + * handler's cleanup would call pthread_mutex_unlock on a mutex this + * thread never owned — UB on PI mutexes. */ + pthread_mutex_lock(image_tables_mutex()); + ctx->holding_mutex = 1; + + /* Per-task scan timing. Every task thread tracks its own + * scan/cycle/latency stats around its body; the IO housekeeping + * window (plugin cycle hooks) is still anchored on the fastest + * task because plugins assume one cross-cycle handoff per scan. */ + scan_cycle_tracker_start(&ctx->tracker); + if (ctx->is_fastest_task) + { + plc_run_io_cycle_pre(); + } + + for (size_t p = 0; p < task->program_count; ++p) + { + task->programs[p]->run(); + } + + if (ctx->is_fastest_task) + { + plc_run_io_cycle_post(); + } + scan_cycle_tracker_end(&ctx->tracker); + + pthread_mutex_unlock(image_tables_mutex()); + ctx->holding_mutex = 0; + + ctx->heartbeat.store((long)time(nullptr), std::memory_order_relaxed); + ctx->local_tick.fetch_add(1, std::memory_order_relaxed); + + next_wakeup.tv_nsec += (long)(ctx->interval_ns % 1000000000LL); + next_wakeup.tv_sec += (time_t)(ctx->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, nullptr); + if (rc == EINTR) continue; + } + + log_info("[task %s] stopped after %llu ticks", ctx->name, + (unsigned long long)ctx->local_tick.load()); + return nullptr; +} + +void *plc_cycle_thread(void *arg) +{ + PluginManager *pm = (PluginManager *)arg; + + plc_crash_signal = 0; + bootstrap_crash_sig = 0; + bootstrap_holding_mutex = 0; + + /* Per-task trackers are initialised below, once we know the task list + * and each task's interval. */ + + lock_memory(); + + if (symbols_init(pm) != 0) + { + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_error("PLC State: ERROR (failed to resolve .so symbols)"); + return NULL; + } + + /* Bind located variables to image-table slots, then fill any + * unbound slots with private backing buffers. */ + pthread_mutex_t *itm = image_tables_mutex(); + pthread_mutex_lock(itm); + image_tables_bind_located_vars(); + image_tables_fill_null_pointers(); + pthread_mutex_unlock(itm); + + journal_buffer_ptrs_t journal_ptrs = { + .bool_input = bool_input, + .bool_output = bool_output, + .bool_memory = bool_memory, + .byte_input = byte_input, + .byte_output = byte_output, + .int_input = int_input, + .int_output = int_output, + .int_memory = int_memory, + .dint_input = dint_input, + .dint_output = dint_output, + .dint_memory = dint_memory, + .lint_input = lint_input, + .lint_output = lint_output, + .lint_memory = lint_memory, + .buffer_size = BUFFER_SIZE, + .image_mutex = itm, + }; + if (journal_init(&journal_ptrs) != 0) + { + log_error("Failed to initialize journal buffer"); + } + else + { + log_info("Journal buffer initialized"); + } + + if (plugin_driver) + { + plugin_driver_start(plugin_driver); + log_info("[PLUGIN]: Enabled plugins started"); + } + + set_realtime_priority(); + + struct sigaction crash_sa; + std::memset(&crash_sa, 0, sizeof(crash_sa)); + crash_sa.sa_handler = plc_crash_handler; + sigemptyset(&crash_sa.sa_mask); + crash_sa.sa_flags = SA_NODEFER; + sigaction(SIGFPE, &crash_sa, NULL); + sigaction(SIGSEGV, &crash_sa, NULL); + + /* SIGUSR1 wake handler is installed once at process init (plc_main.c). + * No per-thread re-installation here — the bootstrap thread inherits + * the handler from the process. */ + + log_info("Starting main loop"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_RUNNING; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: RUNNING"); + + clock_gettime(CLOCK_MONOTONIC, &timer_start); + + int crash_sig = sigsetjmp(bootstrap_crash_jmp, 1); + if (crash_sig != 0) + { + if (bootstrap_holding_mutex) + { + bootstrap_holding_mutex = 0; + pthread_mutex_unlock(itm); + } + const char *sig_name = (crash_sig == SIGFPE) + ? "SIGFPE (arithmetic error, e.g. division by zero)" + : "SIGSEGV (memory access violation)"; + log_error("PLC bootstrap thread crashed with signal %d: %s", crash_sig, sig_name); + + signal(SIGFPE, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + return NULL; + } + + /* Walk the configuration via virtual dispatch and discover the GCD + * base tick + flat task list. Phase 5 keeps a single-thread cycle + * that runs every task in round-robin (each task runs every + * interval/base ticks). Phase 6 will replace this with one thread per + * task on SCHED_FIFO. */ + auto *cfg = static_cast(strucpp_config_handle()); + if (!cfg) + { + log_error("PLC: configuration handle is NULL"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + return NULL; + } + + /* Compute GCD across all task intervals. */ + unsigned long long base_ns = 0; + auto *resources = cfg->get_resources(); + size_t total_tasks = 0; + for (size_t r = 0; r < cfg->get_resource_count(); ++r) + { + for (size_t t = 0; t < resources[r].task_count; ++t) + { + ++total_tasks; + unsigned long long ivl = + (unsigned long long)resources[r].tasks[t].interval_ns; + if (ivl == 0) ivl = 20000000ULL; + if (base_ns == 0) base_ns = ivl; + else + { + unsigned long long a = base_ns, b = ivl; + while (b) { unsigned long long tmp = b; b = a % b; a = tmp; } + base_ns = a; + } + } + } + if (base_ns == 0) base_ns = 20000000ULL; + if (total_tasks == 0) + { + log_error("PLC program declares zero tasks — refusing to run"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + return NULL; + } + log_info("PLC base tick: %llu ns across %zu task(s)", + (unsigned long long)base_ns, total_tasks); + + /* Allocate per-task contexts and spawn one thread per IEC task. + * Hold plc_tasks_lock across the alloc + count publish so a STATS + * reader doesn't observe a non-NULL pointer with the old (zero) + * count, or vice versa. */ + pthread_mutex_lock(&plc_tasks_lock); + plc_tasks = static_cast(std::calloc(total_tasks, sizeof(PlcTaskCtx))); + if (!plc_tasks) + { + pthread_mutex_unlock(&plc_tasks_lock); + log_error("Failed to allocate plc_tasks array"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + return NULL; + } + plc_task_count = total_tasks; + pthread_mutex_unlock(&plc_tasks_lock); + + { + size_t flat_idx = 0; + long now_t = (long)time(nullptr); + for (size_t r = 0; r < cfg->get_resource_count(); ++r) + { + for (size_t t = 0; t < resources[r].task_count; ++t) + { + PlcTaskCtx *ctx = &plc_tasks[flat_idx]; + auto &tk = resources[r].tasks[t]; + ctx->idx = flat_idx; + ctx->interval_ns = tk.interval_ns > 0 ? tk.interval_ns : (int64_t)base_ns; + ctx->priority = tk.priority; + ctx->cpu_affinity_mask = 0; /* Phase 8 will plumb this from CPU_AFFINITY */ + ctx->is_fastest_task = false; /* set below */ + ctx->task_handle = &tk; + if (tk.name && tk.name[0] != '\0') + { + std::snprintf(ctx->name, sizeof ctx->name, "%s", tk.name); + } + else + { + std::snprintf(ctx->name, sizeof ctx->name, "plc-task-%zu", flat_idx); + } + ctx->heartbeat.store(now_t, std::memory_order_relaxed); + ctx->local_tick.store(0, std::memory_order_relaxed); + if (scan_cycle_tracker_init(&ctx->tracker, ctx->interval_ns) != 0) + { + log_error("Failed to init scan-cycle tracker for task %s", ctx->name); + } + ++flat_idx; + } + } + } + + /* Pick the fastest task: smallest interval, tie-break by priority, + * then by declaration order (which is the iteration order above). */ + { + size_t fastest_idx = 0; + for (size_t i = 1; i < plc_task_count; ++i) + { + PlcTaskCtx *c = &plc_tasks[i]; + PlcTaskCtx *f = &plc_tasks[fastest_idx]; + if (c->interval_ns < f->interval_ns || + (c->interval_ns == f->interval_ns && c->priority > f->priority)) + { + fastest_idx = i; + } + } + plc_tasks[fastest_idx].is_fastest_task = true; + log_info("PLC: anchoring housekeeping on task %s " + "(interval=%lld ns, priority=%d)", + plc_tasks[fastest_idx].name, + (long long)plc_tasks[fastest_idx].interval_ns, + plc_tasks[fastest_idx].priority); + } + + /* Spawn task threads. + * + * Failure mode: if pthread_create succeeds for tasks 0..i-1 and then + * fails for task i, the previously-spawned threads are running at + * SCHED_FIFO 99 holding image_tables_mutex and reading from + * plc_tasks[]. Returning here without cleanup leaves them orphaned + * — the next load_plc_program reallocates plc_tasks and the old + * threads dereference freed memory. We must: + * + * 1) flip plc_state to ERROR so the surviving task threads exit + * their `while (state == RUNNING)` loop on their next iteration; + * 2) SIGUSR1 each surviving thread to break it out of its + * clock_nanosleep without waiting up to interval_ns; + * 3) join all spawned threads before freeing the array. + * + * After this rollback, plc_tasks is nullptr and plc_task_count is 0, + * so STATS / next-cycle teardown can run without UAF. */ + size_t spawned = 0; + for (; spawned < plc_task_count; ++spawned) + { + if (pthread_create(&plc_tasks[spawned].thread, NULL, + plc_task_thread, &plc_tasks[spawned]) != 0) + { + log_error("Failed to spawn task %zu thread: %s", + spawned, strerror(errno)); + + /* Flip to ERROR FIRST so the running tasks exit. */ + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + + /* Wake any task currently blocked in clock_nanosleep so it + * notices the state change without waiting a full period. */ + for (size_t k = 0; k < spawned; ++k) + { + pthread_kill(plc_tasks[k].thread, SIGUSR1); + } + for (size_t k = 0; k < spawned; ++k) + { + pthread_join(plc_tasks[k].thread, nullptr); + } + /* Take plc_tasks_lock around the destruction so any STATS + * reader currently iterating exits before we free. */ + pthread_mutex_lock(&plc_tasks_lock); + for (size_t k = 0; k < plc_task_count; ++k) + { + scan_cycle_tracker_cleanup(&plc_tasks[k].tracker); + } + std::free(plc_tasks); + plc_tasks = nullptr; + plc_task_count = 0; + pthread_mutex_unlock(&plc_tasks_lock); + return NULL; + } + } + log_info("Spawned %zu PLC task thread(s)", plc_task_count); + + /* Bootstrap thread: wait for state change, then signal+join the + * task threads. Phase 7 may add the housekeeping window onto the + * fastest task's thread; Phase 6 keeps the bootstrap quiet. */ + while (plc_get_state() == PLC_STATE_RUNNING) + { + timespec poll_sleep; + poll_sleep.tv_sec = 1; + poll_sleep.tv_nsec = 0; + nanosleep(&poll_sleep, nullptr); + } + + log_info("Stopping %zu PLC task thread(s)", plc_task_count); + for (size_t i = 0; i < plc_task_count; ++i) + { + pthread_kill(plc_tasks[i].thread, SIGUSR1); + } + for (size_t i = 0; i < plc_task_count; ++i) + { + pthread_join(plc_tasks[i].thread, nullptr); + } + + /* Take plc_tasks_lock for the tracker-cleanup + free. A STATS reader + * that started iterating before STOP arrived will block briefly + * waiting for this critical section, then exit because plc_task_count + * is observed as 0. Without the lock, the reader could be midway + * through scan_cycle_tracker_snapshot when we pthread_mutex_destroy + * the tracker's own mutex below — undefined behaviour. */ + pthread_mutex_lock(&plc_tasks_lock); + for (size_t i = 0; i < plc_task_count; ++i) + { + scan_cycle_tracker_cleanup(&plc_tasks[i].tracker); + } + std::free(plc_tasks); + plc_tasks = nullptr; + plc_task_count = 0; + pthread_mutex_unlock(&plc_tasks_lock); + + signal(SIGFPE, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + + return NULL; +} + +extern "C" int load_plc_program(PluginManager *pm) +{ + if (pm == NULL) + { + log_error("Failed to load PLC Program: PluginManager is NULL"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + return -1; + } + + if (plugin_manager_load(pm)) + { + log_info("Loading PLC application"); + + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_INIT; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: INIT"); + + if (plugin_driver) + { + if (plugin_driver_update_config(plugin_driver, "./plugins.conf") != 0) + { + log_error("[PLUGIN]: Failed to load plugin configuration"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + if (pm == plc_program) plc_program = NULL; + plugin_manager_destroy(pm); + return -1; + } + /* Load VPP plugins from the editor-generated vpp_plugins.conf. + * The file is absent when the upload had no VPP target, so an + * absent file is silently ignored (not an error). */ + if (plugin_driver_append_config(plugin_driver, "./vpp_plugins.conf") != 0) + { + log_error("[PLUGIN]: VPP plugin failed to load — check vpp_plugins.conf and build/vpp/"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + if (pm == plc_program) plc_program = NULL; + plugin_manager_destroy(pm); + return -1; + } + if (plugin_driver_init(plugin_driver) != 0) + { + /* Roll back any plugins that did initialise before the + * failure. Without this, the next INIT cycle's call to + * plugin_driver_init would invoke init() on top of + * half-allocated state and (e.g.) double-spawn EtherCAT + * masters or OPC-UA sockets. */ + log_error("[PLUGIN]: Plugin init failed — rolling back"); + plugin_driver_cleanup_init(plugin_driver); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + if (pm == plc_program) plc_program = NULL; + plugin_manager_destroy(pm); + return -1; + } + log_info("[PLUGIN]: Plugins re-initialized with updated config"); + } + + if (pthread_create(&plc_thread, NULL, plc_cycle_thread, pm) != 0) + { + log_error("Failed to create PLC cycle thread"); + /* plugin_driver_init succeeded above, so plugins hold init + * state (allocated buffers, opened devices). Roll those back + * before bailing — otherwise the next start retries init() + * on a half-initialised driver. */ + if (plugin_driver) plugin_driver_cleanup_init(plugin_driver); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); + // Drop the manager so the next RUNNING transition re-runs + // find_libplc_file. See the comment on the dlopen-failure + // branch below for the full reasoning. + if (pm == plc_program) plc_program = NULL; + plugin_manager_destroy(pm); + return -1; + } + + return 0; + } + else + { + log_error("Failed to load PLC application"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: EMPTY"); + // Without this, plc_program survives the failed dlopen with a + // stale so_path. The build script rotates the libplc filename + // (libplc_.so) on every successful build, so the + // next "Start PLC" would reuse the deleted path and fail with + // `cannot open shared object file`. Drop the manager and let + // plc_set_state(RUNNING) re-run find_libplc_file next time. + if (pm == plc_program) plc_program = NULL; + plugin_manager_destroy(pm); + return -1; + } +} + +extern "C" int unload_plc_program(PluginManager *pm) +{ + if (pm && pm == plc_program) + { + PLCState prev_state = plc_get_state(); + + if (prev_state != PLC_STATE_ERROR) + { + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_STOPPED; + pthread_mutex_unlock(&state_mutex); + } + + pthread_join(plc_thread, NULL); + + journal_cleanup(); + log_info("Journal buffer cleaned up"); + + plugin_driver_stop(plugin_driver); + + pthread_mutex_t *itm = image_tables_mutex(); + pthread_mutex_lock(itm); + image_tables_clear_null_pointers(); + pthread_mutex_unlock(itm); + + void (*python_cleanup)(void); + *(void **)&python_cleanup = plugin_manager_get_symbol(pm, "python_blocks_cleanup"); + if (python_cleanup) python_cleanup(); + + plugin_manager_destroy(pm); + plc_program = NULL; + + log_info("PLC program unloaded successfully"); + log_info("PLC State: STOPPED"); + return 0; + } + else + { + log_error("No PLC program loaded or mismatched plugin manager"); + return -1; + } +} + +extern "C" PLCState plc_get_state(void) +{ + pthread_mutex_lock(&state_mutex); + PLCState s = plc_state; + pthread_mutex_unlock(&state_mutex); + return s; +} + +extern "C" bool plc_set_state(PLCState new_state) +{ + pthread_mutex_lock(&state_mutex); + if (plc_state == new_state) + { + pthread_mutex_unlock(&state_mutex); + return false; + } + plc_state = new_state; + pthread_mutex_unlock(&state_mutex); + + // Note: plc_state must flip BEFORE load/unload runs. Task threads + // exit their scan loops via `while (plc_get_state() == RUNNING)`, + // and unload_plc_program() depends on that signal to join them. + // The "STATUS reports STOPPED while teardown is still in flight" + // window this opens is gated externally via is_transitioning in + // unix_socket.c — STATUS returns STATUS:TRANSITIONING for the + // duration of the worker, so external pollers don't see the + // stale STOPPED. + + if (new_state == PLC_STATE_RUNNING) + { + if (plc_program == NULL) + { + char *libplc_path = find_libplc_file(libplc_build_dir); + if (libplc_path == NULL) + { + log_error("Failed to find libplc file"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + return false; + } + + plc_program = plugin_manager_create(libplc_path); + free(libplc_path); + + if (plc_program == NULL) + { + log_error("Failed to create PluginManager"); + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_EMPTY; + pthread_mutex_unlock(&state_mutex); + return false; + } + } + if (load_plc_program(plc_program) < 0) + { + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + return false; + } + } + else if (new_state == PLC_STATE_STOPPED) + { + if (plc_program) + { + if (unload_plc_program(plc_program) < 0) return false; + } + } + + return true; +} + +extern "C" void plc_state_manager_cleanup(void) +{ + if (plc_program) unload_plc_program(plc_program); +} + +extern "C" void plc_force_error_state(void) +{ + pthread_mutex_lock(&state_mutex); + plc_state = PLC_STATE_ERROR; + pthread_mutex_unlock(&state_mutex); + log_info("PLC State: ERROR"); +} + +extern "C" int plc_get_crash_signal(void) +{ + return (int)plc_crash_signal; +} diff --git a/core/src/plc_app/plc_state_manager.h b/core/src/plc_app/plc_state_manager.h index 757b6555..b7b602e0 100644 --- a/core/src/plc_app/plc_state_manager.h +++ b/core/src/plc_app/plc_state_manager.h @@ -2,7 +2,27 @@ #define PLC_STATE_MANAGER_H #include "plcapp_manager.h" +#include "scan_cycle_manager.h" +#include +#include +#include #include +#include + +/* Dual-language atomic types: C uses 's _Atomic-typedef + * forms; C++ uses std::atomic. Both have the same memory layout, so + * a struct that contains plc_atomic_long_t compiles in both languages + * and the linker treats it as the same storage. */ +#ifdef __cplusplus +#include +typedef std::atomic plc_atomic_long_t; +typedef std::atomic plc_atomic_u64_t; +extern "C" { +#else +#include +typedef atomic_long plc_atomic_long_t; +typedef atomic_uint_least64_t plc_atomic_u64_t; +#endif typedef enum { @@ -13,6 +33,66 @@ typedef enum PLC_STATE_EMPTY } PLCState; +/* ----------------------------------------------------------------------- + * Per-IEC-task execution context. + * + * One PlcTaskCtx per task declared in the user's CONFIGURATION. Lives + * for the duration of a loaded program; freed on stop. + * + * Per-thread state — crash_jmp, crash_sig, holding_mutex — must NOT be + * shared across threads. Each task thread owns its own context + * exclusively once spawned; the runtime stashes a __thread pointer to + * the active ctx so the signal handler can siglongjmp to the right + * recovery point. + * --------------------------------------------------------------------- */ +typedef struct PlcTaskCtx +{ + size_t idx; /* index into plc_tasks[] */ + int64_t interval_ns; + int priority; /* IEC TASK priority, mapped to SCHED_FIFO */ + uint64_t cpu_affinity_mask; /* 0 = no pinning, kernel decides */ + bool is_fastest_task; /* anchor for housekeeping (Phase 7) */ + void *task_handle; /* opaque strucpp::TaskInstance* */ + pthread_t thread; + char name[32]; + + sigjmp_buf crash_jmp; + volatile sig_atomic_t crash_sig; + volatile sig_atomic_t holding_mutex; + + plc_atomic_long_t heartbeat; + plc_atomic_u64_t local_tick; + + /* Per-task scan/cycle/latency tracker. Each task thread updates its + * own tracker around its scan body; the STATS handler walks all + * trackers to emit per-task entries. Replaces the old single global + * plc_timing_stats from scan_cycle_manager.c which only tracked the + * fastest task. */ + scan_cycle_tracker_t tracker; +} PlcTaskCtx; + +extern PlcTaskCtx *plc_tasks; +extern size_t plc_task_count; + +/* Lifecycle lock for plc_tasks / plc_task_count. + * + * The plc_cycle_thread owns the array — it allocates after walking the + * configuration (load) and frees after joining task threads (stop). + * Concurrently, the unix-socket thread services STATS by iterating the + * array under format_timing_stats_response. is_transitioning gates new + * commands but doesn't bracket an in-flight STATS call: a plugin-initiated + * stop can fire mid-iteration, free plc_tasks, and the STATS reader + * dereferences freed memory. + * + * Readers (STATS) hold this lock for the duration of the iteration. + * The writer (plc_cycle_thread) holds it while allocating, while + * publishing the count, and while freeing. STOP itself doesn't need the + * lock: task threads exit via plc_state observation; the lock only + * brackets the array swap. Held briefly enough that adding latency to + * STATS during a STOP transition is acceptable. */ +void plc_tasks_reader_lock(void); +void plc_tasks_reader_unlock(void); + /** * @brief Get the current PLC state. * @return PLCState The current PLC state @@ -46,4 +126,8 @@ void plc_force_error_state(void); */ int plc_get_crash_signal(void); +#ifdef __cplusplus +} +#endif + #endif // PLC_STATE_MANAGER_H diff --git a/core/src/plc_app/plcapp_manager.h b/core/src/plc_app/plcapp_manager.h index ee94260b..0d54b176 100644 --- a/core/src/plc_app/plcapp_manager.h +++ b/core/src/plc_app/plcapp_manager.h @@ -3,6 +3,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct PluginManager PluginManager; /** @@ -55,4 +59,8 @@ void *plugin_manager_get_symbol(PluginManager *pm, const char *symbol_name); */ #define plugin_manager_get_func(pm, type, name) ((type)plugin_manager_get_symbol((pm), (name))) +#ifdef __cplusplus +} +#endif + #endif // PLUGIN_MANAGER_H diff --git a/core/src/plc_app/scan_cycle_manager.c b/core/src/plc_app/scan_cycle_manager.c index 1750f4c5..60dcab43 100644 --- a/core/src/plc_app/scan_cycle_manager.c +++ b/core/src/plc_app/scan_cycle_manager.c @@ -5,6 +5,7 @@ #include #include +#include "plc_state_manager.h" #include "scan_cycle_manager.h" #include "utils/utils.h" @@ -15,17 +16,11 @@ // minutes of continuous operation. #define OPENPLC_CLOCK CLOCK_MONOTONIC -static uint64_t expected_start_us = 0; -static uint64_t last_start_us = 0; -static pthread_mutex_t stats_mutex; - -plc_timing_stats_t plc_timing_stats = {.scan_time_min = INT64_MAX, - .cycle_latency_min = INT64_MAX, - .cycle_time_avg = 0, - .cycle_time_min = INT64_MAX, - .cycle_latency_avg = 0, - .scan_count = 0, - .overruns = 0}; +// Target wall-clock window for the time-based EWMA averages, in microseconds. +// Matches the editor's 2 s polling cadence so the displayed avg stays stable +// between polls and tracks recent drift rather than freezing as a Welford +// historical mean would after ~10^7 cycles. +#define EWMA_TARGET_WINDOW_US 2000000 static uint64_t ts_now_us(void) { @@ -34,147 +29,191 @@ static uint64_t ts_now_us(void) return (uint64_t)ts.tv_sec * 1000000ull + ts.tv_nsec / 1000; } -void scan_cycle_time_start(void) +int scan_cycle_tracker_init(scan_cycle_tracker_t *tracker, int64_t interval_ns) { - uint64_t now_us = ts_now_us(); - - pthread_mutex_lock(&stats_mutex); + if (!tracker) return -1; + memset(tracker, 0, sizeof(*tracker)); + tracker->stats.scan_time_min = INT64_MAX; + tracker->stats.cycle_time_min = INT64_MAX; + tracker->stats.cycle_latency_min = INT64_MAX; + tracker->interval_ns = interval_ns; + + int64_t interval_us = interval_ns / 1000; + tracker->avg_window = + (interval_us > 0) ? (EWMA_TARGET_WINDOW_US / interval_us) : 1; + if (tracker->avg_window < 1) tracker->avg_window = 1; + + return init_rt_mutex(&tracker->mutex); +} - if (plc_timing_stats.scan_count == 0) - { - // Ignore full calculations for the first cycle - expected_start_us = now_us + *ext_common_ticktime__ / 1000; // Convert ns to us - last_start_us = now_us; - plc_timing_stats.scan_count++; +void scan_cycle_tracker_cleanup(scan_cycle_tracker_t *tracker) +{ + if (!tracker) return; + pthread_mutex_destroy(&tracker->mutex); +} - pthread_mutex_unlock(&stats_mutex); - return; - } +void scan_cycle_tracker_start(scan_cycle_tracker_t *tracker) +{ + if (!tracker) return; + uint64_t now_us = ts_now_us(); - // Calculate cycle time - int64_t cycle_time_us = now_us - last_start_us; - if (cycle_time_us < plc_timing_stats.cycle_time_min) - { - plc_timing_stats.cycle_time_min = cycle_time_us; - } - if (cycle_time_us > plc_timing_stats.cycle_time_max) - { - plc_timing_stats.cycle_time_max = cycle_time_us; - } - plc_timing_stats.cycle_time_avg += - (cycle_time_us - plc_timing_stats.cycle_time_avg) / plc_timing_stats.scan_count; + pthread_mutex_lock(&tracker->mutex); - // Calculate cycle latency - int64_t latency_us = (int64_t)(now_us - expected_start_us); - if (latency_us < plc_timing_stats.cycle_latency_min) + if (tracker->stats.scan_count == 0) { - plc_timing_stats.cycle_latency_min = latency_us; - } - if (latency_us > plc_timing_stats.cycle_latency_max) - { - plc_timing_stats.cycle_latency_max = latency_us; + // First cycle: seed `last_start_us` and the next `expected_start_us` + // anchor; skip latency/cycle-time math for this iteration. + tracker->expected_start_us = now_us + (uint64_t)(tracker->interval_ns / 1000); + tracker->last_start_us = now_us; + tracker->stats.scan_count++; + pthread_mutex_unlock(&tracker->mutex); + return; } - plc_timing_stats.cycle_latency_avg += - (latency_us - plc_timing_stats.cycle_latency_avg) / plc_timing_stats.scan_count; - - last_start_us = now_us; - expected_start_us += *ext_common_ticktime__ / 1000; // Convert ns to us - plc_timing_stats.scan_count++; - - pthread_mutex_unlock(&stats_mutex); + // Cycle time: actual elapsed since previous start + int64_t cycle_time_us = (int64_t)(now_us - tracker->last_start_us); + plc_timing_stats_t *s = &tracker->stats; + if (cycle_time_us < s->cycle_time_min) s->cycle_time_min = cycle_time_us; + if (cycle_time_us > s->cycle_time_max) s->cycle_time_max = cycle_time_us; + tracker->cycle_time_sum += + cycle_time_us - tracker->cycle_time_sum / tracker->avg_window; + + // Cycle latency: how far past the projected wakeup we landed + int64_t latency_us = (int64_t)(now_us - tracker->expected_start_us); + if (latency_us < s->cycle_latency_min) s->cycle_latency_min = latency_us; + if (latency_us > s->cycle_latency_max) s->cycle_latency_max = latency_us; + tracker->cycle_latency_sum += + latency_us - tracker->cycle_latency_sum / tracker->avg_window; + + tracker->last_start_us = now_us; + tracker->expected_start_us += (uint64_t)(tracker->interval_ns / 1000); + + s->scan_count++; + pthread_mutex_unlock(&tracker->mutex); } -void scan_cycle_time_end(void) +void scan_cycle_tracker_end(scan_cycle_tracker_t *tracker) { + if (!tracker) return; uint64_t now_us = ts_now_us(); - pthread_mutex_lock(&stats_mutex); + pthread_mutex_lock(&tracker->mutex); - // Calculate scan time - int64_t scan_time_us = now_us - last_start_us; - if (scan_time_us < plc_timing_stats.scan_time_min) - { - plc_timing_stats.scan_time_min = scan_time_us; - } - if (scan_time_us > plc_timing_stats.scan_time_max) - { - plc_timing_stats.scan_time_max = scan_time_us; - } - plc_timing_stats.scan_time_avg += - (scan_time_us - plc_timing_stats.scan_time_avg) / plc_timing_stats.scan_count; + int64_t scan_time_us = (int64_t)(now_us - tracker->last_start_us); + plc_timing_stats_t *s = &tracker->stats; + if (scan_time_us < s->scan_time_min) s->scan_time_min = scan_time_us; + if (scan_time_us > s->scan_time_max) s->scan_time_max = scan_time_us; + tracker->scan_time_sum += + scan_time_us - tracker->scan_time_sum / tracker->avg_window; - // Check for overrun - if (now_us > expected_start_us) + if (now_us > tracker->expected_start_us) { - plc_timing_stats.overruns++; + s->overruns++; } - pthread_mutex_unlock(&stats_mutex); + pthread_mutex_unlock(&tracker->mutex); } -bool get_timing_stats_snapshot(plc_timing_stats_t *snapshot) +bool scan_cycle_tracker_snapshot(scan_cycle_tracker_t *tracker, plc_timing_stats_t *out) { - if (snapshot == NULL) - { - return false; - } - - pthread_mutex_lock(&stats_mutex); - memcpy(snapshot, &plc_timing_stats, sizeof(plc_timing_stats_t)); - pthread_mutex_unlock(&stats_mutex); - - return snapshot->scan_count > 0; + if (!tracker || !out) return false; + pthread_mutex_lock(&tracker->mutex); + *out = tracker->stats; + out->scan_time_avg = tracker->scan_time_sum / tracker->avg_window; + out->cycle_time_avg = tracker->cycle_time_sum / tracker->avg_window; + out->cycle_latency_avg = tracker->cycle_latency_sum / tracker->avg_window; + pthread_mutex_unlock(&tracker->mutex); + return out->scan_count > 0; } -int scan_cycle_manager_init(void) -{ - return init_rt_mutex(&stats_mutex); -} +// Forward declaration to avoid pulling plc_state_manager.h into the +// header. PlcTaskCtx exposes `name` and a `tracker` field. +extern PlcTaskCtx *plc_tasks; +extern size_t plc_task_count; -void scan_cycle_manager_cleanup(void) +static int append_task_entry(char *buffer, size_t buffer_size, size_t offset, + const char *name, const plc_timing_stats_t *s, + bool valid) { - pthread_mutex_destroy(&stats_mutex); + int written; + if (!valid) + { + written = snprintf(buffer + offset, buffer_size - offset, + "{\"name\":\"%s\"," + "\"scan_count\":0," + "\"scan_time_min\":null," + "\"scan_time_max\":null," + "\"scan_time_avg\":null," + "\"cycle_time_min\":null," + "\"cycle_time_max\":null," + "\"cycle_time_avg\":null," + "\"cycle_latency_min\":null," + "\"cycle_latency_max\":null," + "\"cycle_latency_avg\":null," + "\"overruns\":0}", + name); + } + else + { + written = snprintf(buffer + offset, buffer_size - offset, + "{\"name\":\"%s\"," + "\"scan_count\":%" PRId64 "," + "\"scan_time_min\":%" PRId64 "," + "\"scan_time_max\":%" PRId64 "," + "\"scan_time_avg\":%" PRId64 "," + "\"cycle_time_min\":%" PRId64 "," + "\"cycle_time_max\":%" PRId64 "," + "\"cycle_time_avg\":%" PRId64 "," + "\"cycle_latency_min\":%" PRId64 "," + "\"cycle_latency_max\":%" PRId64 "," + "\"cycle_latency_avg\":%" PRId64 "," + "\"overruns\":%" PRId64 "}", + name, + s->scan_count, s->scan_time_min, s->scan_time_max, s->scan_time_avg, + s->cycle_time_min, s->cycle_time_max, s->cycle_time_avg, + s->cycle_latency_min, s->cycle_latency_max, s->cycle_latency_avg, + s->overruns); + } + if (written < 0) return 0; + return written; } int format_timing_stats_response(char *buffer, size_t buffer_size) { - plc_timing_stats_t snapshot; - bool valid = get_timing_stats_snapshot(&snapshot); - - if (!valid) + if (buffer == NULL || buffer_size == 0) return 0; + + size_t offset = 0; + int n; + + n = snprintf(buffer + offset, buffer_size - offset, "STATS:{\"tasks\":["); + if (n < 0) return 0; + offset += (size_t)n; + + /* Hold plc_tasks_reader_lock for the whole iteration. is_transitioning + * gates new commands but does not bracket an in-flight STATS call — + * a plugin-initiated STOP can fire while we're mid-loop, the bootstrap + * thread joins task threads and frees plc_tasks[], and we'd then read + * freed memory (or worse, lock a destroyed tracker mutex). The lock + * makes the alloc/free critical section in plc_cycle_thread mutually + * exclusive with the iteration here. */ + plc_tasks_reader_lock(); + for (size_t i = 0; i < plc_task_count; ++i) { - return snprintf(buffer, buffer_size, - "STATS:{" - "\"scan_count\":0," - "\"scan_time_min\":null," - "\"scan_time_max\":null," - "\"scan_time_avg\":null," - "\"cycle_time_min\":null," - "\"cycle_time_max\":null," - "\"cycle_time_avg\":null," - "\"cycle_latency_min\":null," - "\"cycle_latency_max\":null," - "\"cycle_latency_avg\":null," - "\"overruns\":0" - "}\n"); + if (i > 0) + { + if (offset >= buffer_size) break; + buffer[offset++] = ','; + } + plc_timing_stats_t snap; + bool valid = scan_cycle_tracker_snapshot(&plc_tasks[i].tracker, &snap); + n = append_task_entry(buffer, buffer_size, offset, plc_tasks[i].name, &snap, valid); + if (n <= 0) break; + offset += (size_t)n; + if (offset >= buffer_size) break; } + plc_tasks_reader_unlock(); - return snprintf(buffer, buffer_size, - "STATS:{" - "\"scan_count\":%" PRId64 "," - "\"scan_time_min\":%" PRId64 "," - "\"scan_time_max\":%" PRId64 "," - "\"scan_time_avg\":%" PRId64 "," - "\"cycle_time_min\":%" PRId64 "," - "\"cycle_time_max\":%" PRId64 "," - "\"cycle_time_avg\":%" PRId64 "," - "\"cycle_latency_min\":%" PRId64 "," - "\"cycle_latency_max\":%" PRId64 "," - "\"cycle_latency_avg\":%" PRId64 "," - "\"overruns\":%" PRId64 "}\n", - snapshot.scan_count, snapshot.scan_time_min, snapshot.scan_time_max, - snapshot.scan_time_avg, snapshot.cycle_time_min, snapshot.cycle_time_max, - snapshot.cycle_time_avg, snapshot.cycle_latency_min, snapshot.cycle_latency_max, - snapshot.cycle_latency_avg, snapshot.overruns); + n = snprintf(buffer + offset, buffer_size - offset, "]}\n"); + if (n > 0) offset += (size_t)n; + return (int)offset; } diff --git a/core/src/plc_app/scan_cycle_manager.h b/core/src/plc_app/scan_cycle_manager.h index da36d955..7a18b591 100644 --- a/core/src/plc_app/scan_cycle_manager.h +++ b/core/src/plc_app/scan_cycle_manager.h @@ -1,9 +1,15 @@ #ifndef SCAN_CYCLE_MANAGER_H #define SCAN_CYCLE_MANAGER_H +#include #include +#include #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct { int64_t scan_time_min; @@ -22,22 +28,63 @@ typedef struct int64_t overruns; } plc_timing_stats_t; -void scan_cycle_time_start(void); -void scan_cycle_time_end(void); +/* Per-task scan-cycle tracker. Each IEC task thread owns one and is the + * exclusive writer; the snapshot reader (the STATS handler) acquires the + * mutex briefly to copy out a consistent view. + * + * `interval_ns` is this task's scheduling period, used to project the + * next-expected start time so latency = actual_start - expected_start + * stays meaningful across tasks with different periods. + * + * Averages use a time-based EWMA targeting a wall-clock window that + * matches the editor's polling cadence (so the displayed avg doesn't + * decorrelate between consecutive polls). Each `*_sum` field holds an + * approximate sum of the last `avg_window` samples; the per-cycle update + * is `sum += sample - sum/avg_window` and the read is `avg = sum/avg_window`. + * This accumulator-then-divide form avoids the integer-precision stall + * of the incremental `avg += (sample - avg)/N` shape when delta < N. */ +typedef struct +{ + plc_timing_stats_t stats; + int64_t scan_time_sum; + int64_t cycle_time_sum; + int64_t cycle_latency_sum; + int64_t avg_window; /* N = target_window_us / interval_us, >= 1 */ + uint64_t expected_start_us; + uint64_t last_start_us; + int64_t interval_ns; + pthread_mutex_t mutex; +} scan_cycle_tracker_t; -// Thread-safe function to get a snapshot of timing stats -// Returns true if stats are valid (scan_count > 0), false otherwise -bool get_timing_stats_snapshot(plc_timing_stats_t *snapshot); +/* Initialise a tracker for a task with the given scheduling interval. + * Call before the task thread enters its scan loop. Returns 0 on + * success, non-zero on mutex-init failure. */ +int scan_cycle_tracker_init(scan_cycle_tracker_t *tracker, int64_t interval_ns); -// Format timing stats as a response string for the STATS command -// Returns the number of characters written (excluding null terminator) -int format_timing_stats_response(char *buffer, size_t buffer_size); +/* Release the tracker's mutex. */ +void scan_cycle_tracker_cleanup(scan_cycle_tracker_t *tracker); -// Initialize scan cycle manager (sets up priority-inheriting mutex) -// Must be called before the scan cycle loop starts -int scan_cycle_manager_init(void); +/* Mark the start / end of a scan body on this tracker's task. Must only + * be called from the task thread that owns the tracker (the mutex is + * for the snapshot reader, not for cross-task synchronisation). */ +void scan_cycle_tracker_start(scan_cycle_tracker_t *tracker); +void scan_cycle_tracker_end(scan_cycle_tracker_t *tracker); + +/* Atomically copy the tracker's stats. Returns true if the task has + * completed at least one full cycle (snapshot is meaningful). */ +bool scan_cycle_tracker_snapshot(scan_cycle_tracker_t *tracker, plc_timing_stats_t *out); + +/* Format the multi-task STATS response. Walks plc_tasks[] and emits + * `STATS:{"tasks":[{...},{...}]}`. Plugins that drive their own threads + * (e.g. the EtherCAT bus thread) report timing through their own + * dedicated channels (in EtherCAT's case the + * `/api/discovery/ethercat/{runtime-status,diagnostics}` routes), not + * through this STATS feed. Returns chars written excluding the null + * terminator. */ +int format_timing_stats_response(char *buffer, size_t buffer_size); -// Cleanup scan cycle manager resources -void scan_cycle_manager_cleanup(void); +#ifdef __cplusplus +} +#endif #endif // SCAN_CYCLE_MANAGER_H diff --git a/core/src/plc_app/unix_socket.c b/core/src/plc_app/unix_socket.c index b23a1c82..8d756437 100644 --- a/core/src/plc_app/unix_socket.c +++ b/core/src/plc_app/unix_socket.c @@ -47,20 +47,51 @@ static void *transition_worker(void *arg) return NULL; } -// Start a background thread that performs the (potentially slow) state transition. -// Returns true if the thread was spawned, false on error. -static bool begin_transition(PLCState target) +// Start a background thread that performs the (potentially slow) state +// transition. Returns true if the thread was spawned, false otherwise. +// +// Two safety guards on the entry path, both targeting plugin-initiated +// stops (which are unsynchronised relative to the unix-socket dispatcher): +// +// 1. CAS on `is_transitioning` 0→1: collapses concurrent calls. A +// misbehaving plugin spinning on plugin_request_plc_stop would +// otherwise pile up detached pthread workers — each one mallocing, +// cloning a thread, and racing for the state mutex. The CAS gate +// means only the first call fires the worker; everything else is a +// cheap return. +// +// 2. Re-check current state AFTER the CAS wins: closes the +// check-then-call race where the caller sees RUNNING, calls in, +// and the state flips to STOPPED before we spawn the worker. We'd +// otherwise dispatch a no-op transition, leaving STATUS reporting +// TRANSITIONING for the worker's lifetime for nothing. +bool plc_begin_transition(PLCState target) { + int expected = 0; + if (!atomic_compare_exchange_strong(&is_transitioning, &expected, 1)) + { + // Another transition is already in flight. Don't pile on. + return false; + } + + if (plc_get_state() == target) + { + // State already at target — release the gate and bail. No + // worker needed; reporting STATUS:TRANSITIONING for a no-op + // would just confuse external pollers. + atomic_store(&is_transitioning, 0); + return false; + } + PLCState *arg = malloc(sizeof(PLCState)); if (!arg) { log_error("Failed to allocate transition argument"); + atomic_store(&is_transitioning, 0); return false; } *arg = target; - atomic_store(&is_transitioning, 1); - pthread_t tid; if (pthread_create(&tid, NULL, transition_worker, arg) != 0) { @@ -97,6 +128,24 @@ static ssize_t read_line(int fd, char *buffer, size_t max_length) static void format_status_response(char *response, size_t response_size) { + // While a transition is in progress, plc_state has already flipped + // to the target value (so the running task threads can exit their + // `while (plc_get_state() == RUNNING)` loops) but the actual + // load/unload work is still happening on the transition worker + // thread. Reporting the bare state here would tell external + // pollers STATUS:STOPPED while the runtime can't yet accept a + // START — they'd race ahead and get COMMAND:BUSY. + // + // Surfacing TRANSITIONING for the duration of the worker keeps + // _wait_for_plc_state(STOPPED) on the webserver side honest: + // STATUS:STOPPED is reported only after the worker has completed + // and is_transitioning has cleared. + if (atomic_load(&is_transitioning)) + { + strncpy(response, "STATUS:TRANSITIONING\n", response_size); + return; + } + PLCState current_state = plc_get_state(); if (current_state == PLC_STATE_INIT) @@ -148,7 +197,7 @@ void handle_unix_socket_commands(const char *command, char *response, size_t res PLCState current_state = plc_get_state(); if (current_state == PLC_STATE_RUNNING) { - if (begin_transition(PLC_STATE_STOPPED)) + if (plc_begin_transition(PLC_STATE_STOPPED)) strncpy(response, "STOP:OK\n", response_size); else strncpy(response, "STOP:ERROR\n", response_size); @@ -163,7 +212,7 @@ void handle_unix_socket_commands(const char *command, char *response, size_t res PLCState current_state = plc_get_state(); if (current_state != PLC_STATE_RUNNING) { - if (begin_transition(PLC_STATE_RUNNING)) + if (plc_begin_transition(PLC_STATE_RUNNING)) strncpy(response, "START:OK\n", response_size); else strncpy(response, "START:ERROR\n", response_size); @@ -177,6 +226,10 @@ void handle_unix_socket_commands(const char *command, char *response, size_t res else if (strcmp(command, "STATS") == 0) { format_timing_stats_response(response, response_size); + // Splice in any plugin-contributed statistics. Safe no-op when no + // plugin exports get_stats. + if (g_plugin_driver) + plugin_driver_append_stats_json(g_plugin_driver, response, response_size); } else if (strncmp(command, "DEBUG:", 6) == 0) { diff --git a/core/src/plc_app/unix_socket.h b/core/src/plc_app/unix_socket.h index 91ec312e..08c98f3a 100644 --- a/core/src/plc_app/unix_socket.h +++ b/core/src/plc_app/unix_socket.h @@ -1,6 +1,10 @@ #ifndef UNIX_SOCKET_H #define UNIX_SOCKET_H +#include + +#include "plc_state_manager.h" + #define SOCKET_PATH "/run/runtime/plc_runtime.socket" #define COMMAND_BUFFER_SIZE 8192 #define MAX_RESPONSE_SIZE 65536 @@ -13,4 +17,10 @@ void *unix_socket_thread(void *arg); // Setter for the plugin driver (called by plc_main after driver creation) void unix_socket_set_plugin_driver(void *driver); +// Spawn a detached worker thread that transitions the PLC to `target`. +// Shared with plugin_driver so a plugin's request_plc_stop callback goes +// through the same transition-guarded path as an external STOP command +// (same overlap protection via the internal is_transitioning flag). +bool plc_begin_transition(PLCState target); + #endif // UNIX_SOCKET_H diff --git a/core/src/plc_app/utils/log.h b/core/src/plc_app/utils/log.h index 8f683806..bac71fd0 100644 --- a/core/src/plc_app/utils/log.h +++ b/core/src/plc_app/utils/log.h @@ -3,6 +3,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + #define LOG_SOCKET_PATH "/run/runtime/log_runtime.socket" typedef enum { @@ -58,4 +62,8 @@ void log_warn(const char *fmt, ...); */ void log_error(const char *fmt, ...); +#ifdef __cplusplus +} +#endif + #endif diff --git a/core/src/plc_app/utils/utils.c b/core/src/plc_app/utils/utils.c index e4c714aa..bebba3a3 100644 --- a/core/src/plc_app/utils/utils.c +++ b/core/src/plc_app/utils/utils.c @@ -21,9 +21,11 @@ #define HAS_REALTIME_FEATURES 0 #endif -unsigned long long *ext_common_ticktime__ = NULL; -unsigned long tick__ = 0; -char *ext_plc_program_md5 = NULL; +/* Default 20 ms; overridden by compute_base_tick_from_config() once the + * .so is loaded and g_config is reachable. */ +uint64_t base_tick_ns = 20000000ULL; +unsigned long scan_counter = 0; +char *ext_strucpp_program_md5 = NULL; void normalize_timespec(struct timespec *ts) { diff --git a/core/src/plc_app/utils/utils.h b/core/src/plc_app/utils/utils.h index 85a32cb8..d7295ce1 100644 --- a/core/src/plc_app/utils/utils.h +++ b/core/src/plc_app/utils/utils.h @@ -10,9 +10,22 @@ #include "log.h" -extern unsigned long long *ext_common_ticktime__; -extern unsigned long tick__; -extern char *ext_plc_program_md5; +#ifdef __cplusplus +extern "C" { +#endif + +/* Base tick interval in nanoseconds — GCD of declared task intervals. + * Computed runtime-side after the .so loads (see image_tables.cpp's + * compute_base_tick_from_config). Default 20 ms before computation. */ +extern uint64_t base_tick_ns; + +/* Scan counter — incremented once per scan cycle by plc_run_io_cycle_post. + * Reported in DEBUG_GET / DEBUG_GET_LIST responses so the editor can + * detect cycle boundaries. */ +extern unsigned long scan_counter; + +/* Project MD5 string (resolved from the .so at load time). */ +extern char *ext_strucpp_program_md5; /** @@ -84,4 +97,8 @@ void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t */ int init_rt_mutex(pthread_mutex_t *mutex); +#ifdef __cplusplus +} +#endif + #endif // UTILS_H diff --git a/core/strucpp_runtime/README.md b/core/strucpp_runtime/README.md new file mode 100644 index 00000000..7f9491a5 --- /dev/null +++ b/core/strucpp_runtime/README.md @@ -0,0 +1,23 @@ +# strucpp_runtime + +Runtime-side asset: + +- `runtime_v4_entry.cpp` — small static C-linkage shim (~50 lines) + compiled into every user `.so` by `scripts/compile.sh`. Defines + `g_config`, exports `strucpp_get_config()` / `strucpp_set_locks()` / + `strucpp_get_located_vars()` / `strucpp_get_located_var_count()`, + and activates the C-linkage debug PDU exports from + `debug_dispatch.hpp` (`STRUCPP_V4_DEBUG_EXPORTS_DEFINE`). + Identical for every project — no per-project codegen. + +The strucpp runtime headers are NOT vendored here. They ship with +each user-program upload under `core/generated/strucpp_runtime/include/`, +and `compile.sh` references them from there when compiling the `.so`. +The runtime executable (`plc_main`) does not include strucpp's full +header set — it uses a small layout-mirror at +`core/include/strucpp_abi.hpp` to walk the configuration via virtual +dispatch. + +ABI contract: `core/include/strucpp_abi.hpp` must match the strucpp +runtime ABI used by the `.so`. When strucpp's ABI version bumps in a +breaking way, that file is the only thing that needs to update. diff --git a/core/strucpp_runtime/runtime_v4_entry.cpp b/core/strucpp_runtime/runtime_v4_entry.cpp new file mode 100644 index 00000000..0de08979 --- /dev/null +++ b/core/strucpp_runtime/runtime_v4_entry.cpp @@ -0,0 +1,108 @@ +// runtime_v4_entry.cpp +// +// Static C-linkage shim compiled into every user .so. Identical for every +// project — no per-project codegen. Lives here in the runtime repo +// because the build (scripts/compile.sh) is the consumer; the editor's +// upload bundle does not ship this file. +// +// Responsibilities: +// +// 1. Instantiate strucpp::Configuration_CONFIG0 g_config — the actual +// object the runtime walks. Must have external linkage so +// generated_debug.cpp's compile-time address-of expressions resolve. +// 2. Export strucpp_get_config() — C-linkage entry the runtime dlsyms +// to obtain a ConfigurationInstance* pointer. +// 3. Export strucpp_set_locks() — runtime-side image-tables / globals +// mutex pointers handed in right after dlopen, stored where +// iec_threading.hpp's lock guards can find them. +// 4. Export strucpp_get_located_vars / strucpp_get_located_var_count +// — re-expose strucpp::locatedVars[] (a per-project namespaced +// symbol) under stable C linkage. +// 5. Activate STRUCPP_V4_DEBUG_EXPORTS_DEFINE — emits the C-linkage +// strucpp_debug_* PDU helpers from debug_dispatch.hpp. +// 6. Export strucpp_advance_time() — bumps the per-.so +// strucpp::__CURRENT_TIME_NS by the runtime-supplied tick. The +// runtime owns the tick (computed from g_config); the shim just +// provides the cross-DSO advance entry point. +// 7. Export strucpp_program_md5 — the project MD5, surfaced by FC 0x45 +// so the editor can verify it's debugging the matching source. + +#define STRUCPP_V4_DEBUG_EXPORTS_DEFINE +#include "debug_dispatch.hpp" +#include "iec_located.hpp" +#include "iec_std_lib.hpp" // ConfigurationInstance + __CURRENT_TIME_NS +#include "generated.hpp" + +#include +#include +#include + +namespace strucpp { + pthread_mutex_t* g_image_tables_mutex_ptr = nullptr; + pthread_mutex_t* g_global_vars_mutex_ptr = nullptr; +} + +// External linkage so generated_debug.cpp can reference &g_config.X.Y at +// compile time. Same constraint as the Arduino sketch's g_config. +strucpp::Configuration_CONFIG0 g_config; + +extern "C" strucpp::ConfigurationInstance* strucpp_get_config(void) { + return &g_config; +} + +extern "C" void strucpp_set_locks(pthread_mutex_t* image_tables_mutex, + pthread_mutex_t* global_vars_mutex) { + strucpp::g_image_tables_mutex_ptr = image_tables_mutex; + strucpp::g_global_vars_mutex_ptr = global_vars_mutex; +} + +// strucpp::locatedVars / locatedVarsCount are top-level externs declared in +// iec_located.hpp and defined per-project by generated.cpp. The runtime +// (loaded once, sees many .so files) can't reach them by mangled name +// portably, so the shim re-exports them via C linkage. Same pattern as +// strucpp_get_config — the runtime walks via these accessors. + +extern "C" const strucpp::LocatedVar *strucpp_get_located_vars(void) { + return strucpp::locatedVars; +} + +extern "C" uint32_t strucpp_get_located_var_count(void) { + return strucpp::locatedVarsCount; +} + +// Project MD5. Used by FC 0x45 to let the editor verify it's debugging +// the program it has the source for. The editor emits +// core/generated/defines.h next to generated.cpp during compile, +// defining PROGRAM_MD5 with the actual program hash. PROGRAM_MD5 is +// the same macro name the Arduino sketch's defines.h uses, keeping a +// single MD5 contract across targets. +// +// No fallback: a program loaded without defines.h is broken and must +// fail to compile (missing file) or link (undefined PROGRAM_MD5). The +// editor's v4 build path always emits defines.h. +#include "defines.h" + +// Define as a non-const char array so: +// 1. The symbol has external linkage (in C++, namespace-scope +// `const` gives INTERNAL linkage, which would hide the symbol +// from dlsym → runtime sees NULL → FC 0x45 returns NOT_LOADED). +// 2. The symbol's address is the start of the string itself, not a +// pointer variable. The runtime's symbols_init does +// `*(void**)&ext_strucpp_program_md5 = dlsym(...)` and indexes +// ext_strucpp_program_md5[i] directly — a `const char *foo = "..."` +// definition would surface the raw pointer bytes as garbage. +// +// extern "C" block expresses C language linkage without the +// "extern initialized" g++ warning that the single-decl form triggers. +extern "C" { +char strucpp_program_md5[] = PROGRAM_MD5; +} + +// Advances the strucpp runtime's scan-cycle clock. Called by the runtime +// once per cycle (the fastest task's housekeeping window invokes it via +// plc_run_io_cycle_post). CODESYS semantics: TIME() returns the same +// value for the duration of a cycle. The tick is supplied by the runtime +// because base_tick_ns is owned runtime-side now. +extern "C" void strucpp_advance_time(uint64_t tick_ns) { + strucpp::__CURRENT_TIME_NS += static_cast(tick_ns); +} diff --git a/install.sh b/install.sh index 9164ed04..f97fc861 100755 --- a/install.sh +++ b/install.sh @@ -210,6 +210,16 @@ install_cmake() { echo "CMake $(cmake --version | head -1) installed" } +# `ccache` is added to every package set below. The runtime's +# scripts/Makefile.strucpp picks it up automatically when present and +# uses it to cache compiled .o files keyed by a hash of the +# preprocessed source + compile flags. The editor uploads the full +# project on every build, but ccache compares CONTENT (not file +# mtime), so unchanged TUs hit the cache and skip recompilation +# entirely. Single-POU edits drop incremental rebuilds from minutes +# to a few seconds. Without ccache the runtime still builds — just +# without the per-file reuse. + # For apt-based distros (Debian, Ubuntu, Linux Mint, Pop!_OS, elementary OS, Zorin, MX Linux, etc.) install_deps_apt() { apt-get update && \ @@ -218,6 +228,7 @@ install_deps_apt() { python3-dev python3-pip python3-venv \ gcc \ make \ + ccache \ pkg-config \ libffi-dev \ ethtool \ @@ -232,7 +243,7 @@ install_deps_apt() { # For yum-based distros (RHEL 7, CentOS 7, Amazon Linux) install_deps_yum() { yum install -y \ - gcc gcc-c++ make cmake \ + gcc gcc-c++ make cmake ccache \ python3 python3-devel python3-pip python3-venv \ && yum clean all } @@ -240,7 +251,7 @@ install_deps_yum() { # For dnf-based distros (Fedora, RHEL 8+, CentOS Stream, Rocky Linux, AlmaLinux, Oracle Linux 8+) install_deps_dnf() { dnf install -y \ - gcc gcc-c++ make cmake \ + gcc gcc-c++ make cmake ccache \ python3 python3-devel python3-pip python3-venv \ && dnf clean all } @@ -253,6 +264,7 @@ install_deps_pacman() { gcc \ make \ cmake \ + ccache \ pkgconf \ python \ python-pip \ @@ -263,7 +275,7 @@ install_deps_pacman() { install_deps_zypper() { zypper refresh && \ zypper install -y \ - gcc gcc-c++ make cmake \ + gcc gcc-c++ make cmake ccache \ python3 python3-devel python3-pip \ pkg-config } @@ -276,6 +288,7 @@ install_deps_apk() { gcc \ make \ cmake \ + ccache \ pkgconf \ python3 python3-dev py3-pip } diff --git a/plugins.conf b/plugins.conf deleted file mode 100644 index 15869e14..00000000 --- a/plugins.conf +++ /dev/null @@ -1,5 +0,0 @@ -modbus_slave,./core/src/drivers/plugins/python/modbus_slave/simple_modbus.py,0,0,./core/src/drivers/plugins/python/modbus_slave/modbus_slave_config.json,./venvs/modbus_slave -modbus_master,./core/src/drivers/plugins/python/modbus_master/modbus_master_plugin.py,0,0,./core/src/drivers/plugins/python/modbus_master/modbus_master.json,./venvs/modbus_master -opcua,./core/src/drivers/plugins/python/opcua/plugin.py,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,1,1,./build/plugins/ethercat.json diff --git a/scripts/Makefile.strucpp b/scripts/Makefile.strucpp new file mode 100644 index 00000000..eb167a22 --- /dev/null +++ b/scripts/Makefile.strucpp @@ -0,0 +1,119 @@ +# Makefile.strucpp — build the user PLC program into build/new_libplc.so +# +# Invoked from scripts/compile.sh as `make -j$(nproc) -f +# scripts/Makefile.strucpp`. Replaces the old serial shell build: +# +# - Per-file compilation rules let `make -j` saturate every core on +# the target platform (Pi 4 = 4×, x86 servers = 8–32×). +# - `wildcard $(GENERATED_DIR)/*.cpp` discovers however many .cpp +# files STruC++ split into. The codegen emits one TU per POU plus +# a shared configuration.cpp, so the file set varies per project +# — keeping the list out of this file means no Makefile churn +# when POUs are added or removed. +# - `ccache` is picked up automatically when present. Re-running a +# build where only one POU's body changed reuses every other .o +# from the cache, so incremental builds drop from minutes to a +# few seconds. + +GENERATED_DIR := core/generated +RUNTIME_INC := $(GENERATED_DIR)/strucpp_runtime/include +PYTHON_INC := core/src/plc_app/include +RUNTIME_SHIM := core/strucpp_runtime/runtime_v4_entry.cpp +PYTHON_LOADER := core/src/plc_app/python_loader.c +BUILD_DIR := build + +# Use ccache transparently when installed. Falls back to the raw +# compiler binary on systems without it. The wrapper sits in front +# of $(CXX); CMake-style "CXX_LAUNCHER" idiom would also work. +CCACHE := $(shell command -v ccache 2>/dev/null) +CXX := $(if $(CCACHE),$(CCACHE) g++,g++) +CC := $(if $(CCACHE),$(CCACHE) gcc,gcc) + +# -O1 (not -O2): generated.cpp is mostly per-element IECVar assignments +# and generated_debug.cpp is mostly constexpr address tables. -O2's +# aggressive inlining and vectorization buy little here while doubling +# compile time on a Pi 4. Hot paths (FB locks, image-tables binding, +# scan-cycle housekeeping) live in the plc_app/ tree built separately +# at higher optimization. -pipe avoids temp files between cc1plus and +# as for an additional 5–10% win. +CXXFLAGS := -std=c++17 -O1 -pipe -fPIC -Wall \ + -Wno-unknown-pragmas -Wno-deprecated-declarations \ + -I $(GENERATED_DIR) -I $(RUNTIME_INC) -I $(PYTHON_INC) + +# Python POU stubs (`{external …}` blocks emitted by the editor) call +# `getpid()`, `create_shm_name()`, `python_block_loader()`. Those +# declarations live in iec_python.h. STruC++ stays Python-unaware — +# same trick MatIEC's compile.sh used: force-include iec_python.h on +# the generated TUs so unqualified references resolve. Stubs without +# any Python POU pay nothing; the header is declarations only. +GENERATED_CXXFLAGS := $(CXXFLAGS) -include iec_python.h + +# Discover every .cpp emitted into core/generated/. STruC++ splits +# across configuration.cpp + one pou_.cpp per POU, but this +# Makefile doesn't care — wildcard adapts. +GEN_CPP := $(wildcard $(GENERATED_DIR)/*.cpp) +GEN_OBJ := $(patsubst $(GENERATED_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(GEN_CPP)) + +SHIM_OBJ := $(BUILD_DIR)/runtime_v4_entry.o +PYTHON_OBJ := $(if $(wildcard $(PYTHON_LOADER)),$(BUILD_DIR)/python_loader.o,) +CBLOCKS_CPP := $(wildcard $(GENERATED_DIR)/c_blocks_code.cpp) +CBLOCKS_OBJ := $(patsubst $(GENERATED_DIR)/%.cpp,$(BUILD_DIR)/%.o,$(CBLOCKS_CPP)) + +LIBPLC := $(BUILD_DIR)/new_libplc.so + +# Cygwin/MSYS2: TCP/UDP communication blocks aren't supported, fall +# back to no-op stubs so blocks-using programs still link. +UNAME_S := $(shell uname -s) +IS_WINDOWS := $(if $(filter CYGWIN_NT-% MSYS_NT-% MINGW64_NT-% MINGW32_NT-%,$(UNAME_S)),1,) +COMM_STUB_OBJ := $(if $(IS_WINDOWS),$(BUILD_DIR)/comm_stubs.o,) + +EXTRA_OBJS := $(CBLOCKS_OBJ) $(PYTHON_OBJ) $(COMM_STUB_OBJ) + +.PHONY: all clean +.DEFAULT_GOAL := all + +all: $(LIBPLC) + +# Final link. Order matters only for static library resolution; .o +# objects are interchangeable. -lpthread / -lrt match the historical +# compile.sh. +$(LIBPLC): $(GEN_OBJ) $(SHIM_OBJ) $(EXTRA_OBJS) | $(BUILD_DIR) + g++ -shared -fPIC -o $@ $^ -lpthread -lrt + @echo "[INFO] Build complete: $@" + +# Generated TUs from the editor's upload. Force-include iec_python.h +# so unqualified Python loader symbols in {external} blocks resolve. +$(BUILD_DIR)/%.o: $(GENERATED_DIR)/%.cpp | $(BUILD_DIR) + @echo "[INFO] Compiling $<..." + $(CXX) $(GENERATED_CXXFLAGS) -c $< -o $@ + +# Static runtime shim — lives in the runtime repo, not in the user +# upload. Doesn't get the iec_python.h force-include. +$(SHIM_OBJ): $(RUNTIME_SHIM) | $(BUILD_DIR) + @echo "[INFO] Compiling runtime_v4_entry.cpp..." + $(CXX) $(CXXFLAGS) -c $< -o $@ + +# Python loader (C, not C++). Compiled with the runtime tree's local +# include path so it sees iec_python.h alongside its own headers. +$(BUILD_DIR)/python_loader.o: $(PYTHON_LOADER) | $(BUILD_DIR) + @echo "[INFO] Compiling python_loader.c..." + $(CC) -O2 -fPIC -I core/src/plc_app -c $< -o $@ + +# Cygwin TCP/UDP stub — written on demand, then compiled. +$(BUILD_DIR)/comm_stubs.o: | $(BUILD_DIR) + @echo "[INFO] Generating + compiling Cygwin comm stubs..." + @printf '%s\n' \ + '#include ' \ + '#include ' \ + 'int connect_to_tcp_server(uint8_t *a, uint16_t b, int c) { (void)a; (void)b; (void)c; return -1; }' \ + 'int send_tcp_message(uint8_t *a, size_t b, int c) { (void)a; (void)b; (void)c; return -1; }' \ + 'int receive_tcp_message(uint8_t *a, size_t b, int c) { (void)a; (void)b; (void)c; return -1; }' \ + 'int close_tcp_connection(int a) { (void)a; return -1; }' \ + > $(BUILD_DIR)/comm_stubs.c + gcc -O2 -fPIC -c $(BUILD_DIR)/comm_stubs.c -o $@ + +$(BUILD_DIR): + @mkdir -p $@ + +clean: + rm -rf $(BUILD_DIR) diff --git a/scripts/compile.sh b/scripts/compile.sh index 9955c072..c743af05 100755 --- a/scripts/compile.sh +++ b/scripts/compile.sh @@ -1,87 +1,116 @@ #!/bin/bash +# +# compile.sh — build the user PLC program into core/build/new_libplc.so +# +# This script is the entry point the runtime's webserver calls after +# extracting an upload into core/generated/. The actual build rules +# live in scripts/Makefile.strucpp — invoking `make` lets us: +# +# - run per-file compilation in parallel via `-j$(nproc)` (Pi 4 = 4×, +# workstations more); +# - reuse cached .o files automatically when ccache is installed, +# so incremental rebuilds (one POU edited) drop from minutes to +# a few seconds; +# - adapt to whatever .cpp set STruC++'s codegen split emitted — +# the Makefile uses `wildcard $(GENERATED_DIR)/*.cpp` rather than +# a hard-coded list. +# +# The MatIEC-era files (Config0.c, Res0.c, debug.c, glueVars.c) are +# rejected with a clear error so stale uploads fail loudly instead of +# silently building against the old pipeline. + set -euo pipefail -# Paths -ROOT="core/generated" -LIB_PATH="$ROOT/lib" -SRC_PATH="$ROOT" -BUILD_PATH="build" -PYTHON_INCLUDE_PATH="core/src/plc_app/include" -PYTHON_LOADER_SRC="core/src/plc_app/python_loader.c" +GENERATED_DIR="core/generated" +RUNTIME_SHIM="core/strucpp_runtime/runtime_v4_entry.cpp" +RUNTIME_INC="$GENERATED_DIR/strucpp_runtime/include" -FLAGS="-w -O3 -fPIC" +# Output path the Makefile drops new_libplc.so into. Also where any +# user-supplied VPP plugin .so will land so the runtime's plugin +# loader can pick them up under the same lookup rules. +BUILD_PATH="build" check_required_files() { - local missing_files=() - - if [ ! -f "$SRC_PATH/Config0.c" ]; then - missing_files+=("$SRC_PATH/Config0.c") - fi - if [ ! -f "$SRC_PATH/Res0.c" ]; then - missing_files+=("$SRC_PATH/Res0.c") - fi - if [ ! -f "$SRC_PATH/debug.c" ]; then - missing_files+=("$SRC_PATH/debug.c") - fi - if [ ! -f "$SRC_PATH/glueVars.c" ]; then - missing_files+=("$SRC_PATH/glueVars.c") - fi - if [ ! -d "$LIB_PATH" ]; then - missing_files+=("$LIB_PATH (directory)") + if [ -f "$GENERATED_DIR/Config0.c" ] || [ -f "$GENERATED_DIR/glueVars.c" ]; then + echo "[ERROR] core/generated contains MatIEC files (Config0.c / glueVars.c)." >&2 + echo " This runtime no longer supports MatIEC programs." >&2 + echo " Re-export the project from a STruC++-aware editor build." >&2 + exit 2 fi - if [ ${#missing_files[@]} -ne 0 ]; then + local missing=() + [ -f "$GENERATED_DIR/generated.hpp" ] || missing+=("$GENERATED_DIR/generated.hpp") + [ -n "$(ls "$GENERATED_DIR"/*.cpp 2>/dev/null)" ] || missing+=("$GENERATED_DIR/*.cpp (at least one)") + [ -f "$RUNTIME_SHIM" ] || missing+=("$RUNTIME_SHIM") + [ -d "$RUNTIME_INC" ] || missing+=("$RUNTIME_INC (directory)") + + if [ ${#missing[@]} -ne 0 ]; then echo "[ERROR] Missing required source files:" >&2 - printf ' %s\n' "${missing_files[@]}" >&2 + printf ' %s\n' "${missing[@]}" >&2 exit 1 fi } check_required_files -# Ensure build directory exists -mkdir -p "$BUILD_PATH" -if [ ! -d "$BUILD_PATH" ]; then - echo "[ERROR] Failed to create build directory: $BUILD_PATH" >&2 - exit 1 -fi +# Build the program — actual rules live in scripts/Makefile.strucpp. +make -j"$(nproc)" -f scripts/Makefile.strucpp + +# ----------------------------------------------------------------------- +# Compile VPP plugin if source is present in the uploaded project +# +# The editor ships an optional vpp_plugin/ subtree alongside the IEC +# program when the project includes a VPP package. The plugin builds +# into BUILD_PATH (next to new_libplc.so) so the runtime's plugin +# loader picks it up under the same lookup rules as built-ins. +# +# Checksum cache: skip recompilation when the source hasn't changed +# AND a previous build's .so is still present. Saves ~20-60 s per +# upload on the slowest targets. +# ----------------------------------------------------------------------- +VPP_PLUGIN_DIR="$GENERATED_DIR/vpp_plugin" +VPP_CHECKSUM_FILE="$VPP_PLUGIN_DIR/checksum.sha256" +# VPP outputs land in a dedicated subdir of BUILD_PATH so the cleanup +# glob below can scope itself to VPP-only artefacts. If a future built-in +# plugin ships as a .so dropped into BUILD_PATH directly, the old +# "$BUILD_PATH/lib*_plugin.so" glob would have rm'd it on every upload +# without a vpp_plugin subtree present. +VPP_OUTPUT_DIR="$BUILD_PATH/vpp" +VPP_CACHED_CHECKSUM="$VPP_OUTPUT_DIR/checksum.sha256" + +if [ -d "$VPP_PLUGIN_DIR" ] && [ -f "$VPP_PLUGIN_DIR/Makefile" ]; then + NEEDS_COMPILE=1 + mkdir -p "$VPP_OUTPUT_DIR" -# On Cygwin/MSYS2, TCP/UDP communication blocks are not supported (the PE -# loader cannot resolve symbols from the host executable at dlopen time). -# Provide no-op stubs so programs using these blocks still compile and run -# — the blocks simply return -1 (failure) for every operation. -EXTRA_OBJS="" -case "$(uname -s)" in - CYGWIN*|MSYS*|MINGW*) - cat > "$BUILD_PATH/comm_stubs.c" << 'STUB' -#include -#include -int connect_to_tcp_server(uint8_t *a, uint16_t b, int c) { (void)a; (void)b; (void)c; return -1; } -int send_tcp_message(uint8_t *a, size_t b, int c) { (void)a; (void)b; (void)c; return -1; } -int receive_tcp_message(uint8_t *a, size_t b, int c) { (void)a; (void)b; (void)c; return -1; } -int close_tcp_connection(int a) { (void)a; return -1; } -STUB - gcc $FLAGS -c "$BUILD_PATH/comm_stubs.c" -o "$BUILD_PATH/comm_stubs.o" - EXTRA_OBJS="$BUILD_PATH/comm_stubs.o" - ;; -esac + if [ -f "$VPP_CHECKSUM_FILE" ] && [ -f "$VPP_CACHED_CHECKSUM" ]; then + if diff -q "$VPP_CHECKSUM_FILE" "$VPP_CACHED_CHECKSUM" > /dev/null 2>&1; then + if ls "$VPP_OUTPUT_DIR"/lib*_plugin.so 1>/dev/null 2>&1; then + echo "[INFO] VPP plugin source unchanged (checksum match), skipping recompilation" + NEEDS_COMPILE=0 + fi + fi + fi -# Compile objects into build/ -echo "[INFO] Compiling Config0.c..." -gcc $FLAGS -I "$LIB_PATH" -I "$PYTHON_INCLUDE_PATH" -include iec_python.h -c "$SRC_PATH/Config0.c" -o "$BUILD_PATH/Config0.o" -echo "[INFO] Compiling Res0.c..." -gcc $FLAGS -I "$LIB_PATH" -I "$PYTHON_INCLUDE_PATH" -include iec_python.h -c "$SRC_PATH/Res0.c" -o "$BUILD_PATH/Res0.o" -echo "[INFO] Compiling debug.c..." -gcc $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/debug.c" -o "$BUILD_PATH/debug.o" -echo "[INFO] Compiling glueVars.c..." -gcc $FLAGS -I "$LIB_PATH" -DOPENPLC_V4 -c "$SRC_PATH/glueVars.c" -o "$BUILD_PATH/glueVars.o" -echo "[INFO] Compiling c_blocks_code.cpp..." -g++ $FLAGS -I "$LIB_PATH" -c "$SRC_PATH/c_blocks_code.cpp" -o "$BUILD_PATH/c_blocks_code.o" -echo "[INFO] Compiling python_loader.c..." -gcc $FLAGS -I "core/src/plc_app" -c "$PYTHON_LOADER_SRC" -o "$BUILD_PATH/python_loader.o" + if [ "$NEEDS_COMPILE" -eq 1 ]; then + echo "[INFO] Compiling VPP plugin from $VPP_PLUGIN_DIR..." + PLUGIN_INCLUDE="-I $(pwd)/core/src/drivers -I $(pwd)/core/src/drivers/plugins/native -I $(pwd)/core/src/drivers/plugins/native/cjson -I $(pwd)/core/src/plc_app -I $(pwd)/core/lib" + make -C "$VPP_PLUGIN_DIR" \ + INCLUDE_DIRS="$PLUGIN_INCLUDE" \ + OUTPUT_DIR="$(pwd)/$VPP_OUTPUT_DIR" \ + RUNTIME_ROOT="$(pwd)" -# Link shared library into build/ -echo "[INFO] Linking shared library..." -g++ $FLAGS -shared -o "$BUILD_PATH/new_libplc.so" "$BUILD_PATH/Config0.o" \ - "$BUILD_PATH/Res0.o" "$BUILD_PATH/debug.o" "$BUILD_PATH/glueVars.o" \ - "$BUILD_PATH/c_blocks_code.o" "$BUILD_PATH/python_loader.o" $EXTRA_OBJS -lpthread -lrt + if [ -f "$VPP_CHECKSUM_FILE" ]; then + cp "$VPP_CHECKSUM_FILE" "$VPP_CACHED_CHECKSUM" + fi + echo "[INFO] VPP plugin compiled successfully" + fi +else + # No VPP plugin in this upload — clean up the entire VPP output dir + # so a stale .so doesn't get picked up by the loader. Scoping the rm + # to VPP_OUTPUT_DIR (instead of a glob in BUILD_PATH) keeps cleanup + # isolated from anything else that ends up in BUILD_PATH. + if [ -d "$VPP_OUTPUT_DIR" ]; then + echo "[INFO] No VPP plugin in upload, removing $VPP_OUTPUT_DIR" + rm -rf "$VPP_OUTPUT_DIR" + fi +fi diff --git a/tests/pytest/plugins/opcua/test_memory.py b/tests/pytest/plugins/opcua/test_memory.py deleted file mode 100644 index f53ad602..00000000 --- a/tests/pytest/plugins/opcua/test_memory.py +++ /dev/null @@ -1,469 +0,0 @@ -""" -Unit tests for OPC-UA memory access functions. - -Tests the functions in opcua_memory.py: -- read_memory_direct() -- read_string_direct() -- write_string_direct() -- IEC_STRING structure -""" - -import pytest -import ctypes -import sys -from pathlib import Path - -# Add plugin path for imports -_plugin_dir = Path(__file__).parent.parent.parent.parent.parent / "core" / "src" / "drivers" / "plugins" / "python" -sys.path.insert(0, str(_plugin_dir / "opcua")) - -from opcua_memory import ( - IEC_STRING, - IEC_TIMESPEC, - STR_MAX_LEN, - STRING_TOTAL_SIZE, - TIMESPEC_SIZE, - TIME_DATATYPES, - read_memory_direct, - read_string_direct, - write_string_direct, - read_timespec_direct, - write_timespec_direct, -) - - -class TestIECStringStructure: - """Tests for the IEC_STRING ctypes structure.""" - - def test_structure_size(self): - """IEC_STRING should be 127 bytes (1 byte len + 126 bytes body).""" - assert ctypes.sizeof(IEC_STRING) == STRING_TOTAL_SIZE - assert ctypes.sizeof(IEC_STRING) == 127 - - def test_str_max_len_constant(self): - """STR_MAX_LEN should be 126.""" - assert STR_MAX_LEN == 126 - - def test_structure_fields(self): - """IEC_STRING should have len and body fields.""" - iec_string = IEC_STRING() - assert hasattr(iec_string, 'len') - assert hasattr(iec_string, 'body') - - def test_structure_initialization(self): - """IEC_STRING should initialize with zeros.""" - iec_string = IEC_STRING() - assert iec_string.len == 0 - assert all(b == 0 for b in iec_string.body) - - def test_structure_len_field(self): - """len field should accept int8 values.""" - iec_string = IEC_STRING() - iec_string.len = 10 - assert iec_string.len == 10 - - iec_string.len = 126 - assert iec_string.len == 126 - - def test_structure_body_field(self): - """body field should accept byte values.""" - iec_string = IEC_STRING() - iec_string.body[0] = ord('H') - iec_string.body[1] = ord('i') - assert iec_string.body[0] == ord('H') - assert iec_string.body[1] == ord('i') - - -class TestReadStringDirect: - """Tests for read_string_direct function using simulated memory.""" - - def _create_iec_string_in_memory(self, text: str) -> tuple: - """ - Create an IEC_STRING in memory and return (address, struct). - - Args: - text: String to store - - Returns: - Tuple of (memory_address, IEC_STRING_instance) - """ - iec_string = IEC_STRING() - - # Encode and truncate - encoded = text.encode('utf-8')[:STR_MAX_LEN] - iec_string.len = len(encoded) - - # Copy bytes - for i, b in enumerate(encoded): - iec_string.body[i] = b - - # Get address - address = ctypes.addressof(iec_string) - return address, iec_string - - def test_read_empty_string(self): - """Should read empty string correctly.""" - address, iec_string = self._create_iec_string_in_memory("") - result = read_string_direct(address) - assert result == "" - - def test_read_short_string(self): - """Should read short string correctly.""" - address, iec_string = self._create_iec_string_in_memory("Hello") - result = read_string_direct(address) - assert result == "Hello" - - def test_read_medium_string(self): - """Should read medium-length string correctly.""" - text = "Hello OPC-UA World!" - address, iec_string = self._create_iec_string_in_memory(text) - result = read_string_direct(address) - assert result == text - - def test_read_max_length_string(self): - """Should read maximum length string correctly.""" - text = "A" * STR_MAX_LEN - address, iec_string = self._create_iec_string_in_memory(text) - result = read_string_direct(address) - assert result == text - assert len(result) == STR_MAX_LEN - - def test_read_string_with_spaces(self): - """Should handle strings with spaces.""" - text = "Hello World Test" - address, iec_string = self._create_iec_string_in_memory(text) - result = read_string_direct(address) - assert result == text - - def test_read_string_with_numbers(self): - """Should handle strings with numbers.""" - text = "Value: 12345" - address, iec_string = self._create_iec_string_in_memory(text) - result = read_string_direct(address) - assert result == text - - -class TestWriteStringDirect: - """Tests for write_string_direct function.""" - - def _create_empty_iec_string(self) -> tuple: - """Create an empty IEC_STRING and return (address, struct).""" - iec_string = IEC_STRING() - address = ctypes.addressof(iec_string) - return address, iec_string - - def test_write_empty_string(self): - """Should write empty string correctly.""" - address, iec_string = self._create_empty_iec_string() - result = write_string_direct(address, "") - assert result is True - assert iec_string.len == 0 - - def test_write_short_string(self): - """Should write short string correctly.""" - address, iec_string = self._create_empty_iec_string() - result = write_string_direct(address, "Test") - assert result is True - assert iec_string.len == 4 - assert bytes(iec_string.body[:4]) == b"Test" - - def test_write_max_length_string(self): - """Should write maximum length string correctly.""" - address, iec_string = self._create_empty_iec_string() - text = "B" * STR_MAX_LEN - result = write_string_direct(address, text) - assert result is True - assert iec_string.len == STR_MAX_LEN - - def test_write_truncates_long_string(self): - """Should truncate strings longer than STR_MAX_LEN.""" - address, iec_string = self._create_empty_iec_string() - text = "C" * (STR_MAX_LEN + 50) - result = write_string_direct(address, text) - assert result is True - assert iec_string.len == STR_MAX_LEN - - def test_write_then_read_roundtrip(self): - """Should support write then read roundtrip.""" - address, iec_string = self._create_empty_iec_string() - original = "OpenPLC Runtime" - - write_string_direct(address, original) - result = read_string_direct(address) - - assert result == original - - -class TestReadMemoryDirectWithString: - """Tests for read_memory_direct with STRING type (size 127).""" - - def _create_iec_string_in_memory(self, text: str) -> tuple: - """Create an IEC_STRING in memory.""" - iec_string = IEC_STRING() - encoded = text.encode('utf-8')[:STR_MAX_LEN] - iec_string.len = len(encoded) - for i, b in enumerate(encoded): - iec_string.body[i] = b - address = ctypes.addressof(iec_string) - return address, iec_string - - def test_read_memory_direct_string_size(self): - """read_memory_direct should handle size 127 as STRING.""" - address, iec_string = self._create_iec_string_in_memory("Direct Test") - result = read_memory_direct(address, STRING_TOTAL_SIZE) - assert result == "Direct Test" - - def test_read_memory_direct_string_empty(self): - """read_memory_direct should handle empty STRING.""" - address, iec_string = self._create_iec_string_in_memory("") - result = read_memory_direct(address, STRING_TOTAL_SIZE) - assert result == "" - - -class TestReadMemoryDirectNumeric: - """Tests for read_memory_direct with numeric types.""" - - def test_read_uint8(self): - """Should read 1-byte value correctly.""" - value = ctypes.c_uint8(42) - address = ctypes.addressof(value) - result = read_memory_direct(address, 1) - assert result == 42 - - def test_read_uint16(self): - """Should read 2-byte value correctly.""" - value = ctypes.c_uint16(1000) - address = ctypes.addressof(value) - result = read_memory_direct(address, 2) - assert result == 1000 - - def test_read_uint32(self): - """Should read 4-byte value correctly.""" - value = ctypes.c_uint32(100000) - address = ctypes.addressof(value) - result = read_memory_direct(address, 4) - assert result == 100000 - - def test_read_uint64(self): - """Should read 8-byte value correctly.""" - value = ctypes.c_uint64(1000000000) - address = ctypes.addressof(value) - result = read_memory_direct(address, 8) - assert result == 1000000000 - - def test_unsupported_size_raises(self): - """Should raise ValueError for unsupported sizes.""" - value = ctypes.c_uint8(0) - address = ctypes.addressof(value) - - with pytest.raises(RuntimeError) as exc_info: - read_memory_direct(address, 3) - assert "Unsupported variable size" in str(exc_info.value) - - with pytest.raises(RuntimeError) as exc_info: - read_memory_direct(address, 16) - assert "Unsupported variable size" in str(exc_info.value) - - -class TestIECTimespecStructure: - """Tests for the IEC_TIMESPEC ctypes structure.""" - - def test_structure_size(self): - """IEC_TIMESPEC should be 8 bytes (2 x int32).""" - assert ctypes.sizeof(IEC_TIMESPEC) == TIMESPEC_SIZE - assert ctypes.sizeof(IEC_TIMESPEC) == 8 - - def test_timespec_size_constant(self): - """TIMESPEC_SIZE should be 8.""" - assert TIMESPEC_SIZE == 8 - - def test_structure_fields(self): - """IEC_TIMESPEC should have tv_sec and tv_nsec fields.""" - timespec = IEC_TIMESPEC() - assert hasattr(timespec, 'tv_sec') - assert hasattr(timespec, 'tv_nsec') - - def test_structure_initialization(self): - """IEC_TIMESPEC should initialize with zeros.""" - timespec = IEC_TIMESPEC() - assert timespec.tv_sec == 0 - assert timespec.tv_nsec == 0 - - def test_structure_tv_sec_field(self): - """tv_sec field should accept int32 values.""" - timespec = IEC_TIMESPEC() - timespec.tv_sec = 3600 - assert timespec.tv_sec == 3600 - - timespec.tv_sec = -100 - assert timespec.tv_sec == -100 - - def test_structure_tv_nsec_field(self): - """tv_nsec field should accept int32 values.""" - timespec = IEC_TIMESPEC() - timespec.tv_nsec = 500_000_000 - assert timespec.tv_nsec == 500_000_000 - - -class TestReadTimespecDirect: - """Tests for read_timespec_direct function.""" - - def _create_timespec_in_memory(self, tv_sec: int, tv_nsec: int) -> tuple: - """ - Create an IEC_TIMESPEC in memory and return (address, struct). - """ - timespec = IEC_TIMESPEC() - timespec.tv_sec = tv_sec - timespec.tv_nsec = tv_nsec - address = ctypes.addressof(timespec) - return address, timespec - - def test_read_zero_time(self): - """Should read zero time correctly.""" - address, timespec = self._create_timespec_in_memory(0, 0) - result = read_timespec_direct(address) - assert result == (0, 0) - - def test_read_seconds_only(self): - """Should read time with only seconds.""" - address, timespec = self._create_timespec_in_memory(100, 0) - result = read_timespec_direct(address) - assert result == (100, 0) - - def test_read_with_nanoseconds(self): - """Should read time with nanoseconds.""" - address, timespec = self._create_timespec_in_memory(1, 500_000_000) - result = read_timespec_direct(address) - assert result == (1, 500_000_000) - - def test_read_large_time(self): - """Should read large time values (hours/days).""" - # 24 hours - address, timespec = self._create_timespec_in_memory(86400, 0) - result = read_timespec_direct(address) - assert result == (86400, 0) - - def test_read_negative_seconds(self): - """Should handle negative seconds (for negative time intervals).""" - address, timespec = self._create_timespec_in_memory(-10, 0) - result = read_timespec_direct(address) - assert result == (-10, 0) - - -class TestWriteTimespecDirect: - """Tests for write_timespec_direct function.""" - - def _create_empty_timespec(self) -> tuple: - """Create an empty IEC_TIMESPEC and return (address, struct).""" - timespec = IEC_TIMESPEC() - address = ctypes.addressof(timespec) - return address, timespec - - def test_write_zero_time(self): - """Should write zero time correctly.""" - address, timespec = self._create_empty_timespec() - result = write_timespec_direct(address, 0, 0) - assert result is True - assert timespec.tv_sec == 0 - assert timespec.tv_nsec == 0 - - def test_write_seconds_only(self): - """Should write time with only seconds.""" - address, timespec = self._create_empty_timespec() - result = write_timespec_direct(address, 100, 0) - assert result is True - assert timespec.tv_sec == 100 - assert timespec.tv_nsec == 0 - - def test_write_with_nanoseconds(self): - """Should write time with nanoseconds.""" - address, timespec = self._create_empty_timespec() - result = write_timespec_direct(address, 1, 500_000_000) - assert result is True - assert timespec.tv_sec == 1 - assert timespec.tv_nsec == 500_000_000 - - def test_write_large_time(self): - """Should write large time values.""" - address, timespec = self._create_empty_timespec() - result = write_timespec_direct(address, 86400, 999_000_000) - assert result is True - assert timespec.tv_sec == 86400 - assert timespec.tv_nsec == 999_000_000 - - def test_write_then_read_roundtrip(self): - """Should support write then read roundtrip.""" - address, timespec = self._create_empty_timespec() - - write_timespec_direct(address, 3600, 250_000_000) - result = read_timespec_direct(address) - - assert result == (3600, 250_000_000) - - -class TestReadMemoryDirectWithTimeDatatype: - """Tests for read_memory_direct with TIME datatype hint.""" - - def _create_timespec_in_memory(self, tv_sec: int, tv_nsec: int) -> tuple: - """Create an IEC_TIMESPEC in memory.""" - timespec = IEC_TIMESPEC() - timespec.tv_sec = tv_sec - timespec.tv_nsec = tv_nsec - address = ctypes.addressof(timespec) - return address, timespec - - def test_read_memory_direct_time_with_datatype(self): - """read_memory_direct should return tuple for TIME datatype.""" - address, timespec = self._create_timespec_in_memory(10, 500_000_000) - result = read_memory_direct(address, 8, datatype="TIME") - assert result == (10, 500_000_000) - - def test_read_memory_direct_tod_with_datatype(self): - """read_memory_direct should return tuple for TOD datatype.""" - address, timespec = self._create_timespec_in_memory(3600, 0) - result = read_memory_direct(address, 8, datatype="TOD") - assert result == (3600, 0) - - def test_read_memory_direct_date_with_datatype(self): - """read_memory_direct should return tuple for DATE datatype.""" - address, timespec = self._create_timespec_in_memory(86400, 0) - result = read_memory_direct(address, 8, datatype="DATE") - assert result == (86400, 0) - - def test_read_memory_direct_dt_with_datatype(self): - """read_memory_direct should return tuple for DT datatype.""" - address, timespec = self._create_timespec_in_memory(1000000, 123_000_000) - result = read_memory_direct(address, 8, datatype="DT") - assert result == (1000000, 123_000_000) - - def test_read_memory_direct_8bytes_without_datatype(self): - """read_memory_direct should return uint64 for 8 bytes without datatype hint.""" - value = ctypes.c_uint64(1000000000) - address = ctypes.addressof(value) - result = read_memory_direct(address, 8) - assert result == 1000000000 - assert isinstance(result, int) - - def test_read_memory_direct_time_case_insensitive(self): - """read_memory_direct should handle case-insensitive datatype.""" - address, timespec = self._create_timespec_in_memory(5, 100_000_000) - result = read_memory_direct(address, 8, datatype="time") - assert result == (5, 100_000_000) - - result = read_memory_direct(address, 8, datatype="Time") - assert result == (5, 100_000_000) - - -class TestTimeDatatypesConstantMemory: - """Tests for TIME_DATATYPES constant in memory module.""" - - def test_time_datatypes_contains_all_time_types(self): - """TIME_DATATYPES should contain all time-related types.""" - assert "TIME" in TIME_DATATYPES - assert "DATE" in TIME_DATATYPES - assert "TOD" in TIME_DATATYPES - assert "DT" in TIME_DATATYPES - - def test_time_datatypes_is_frozen(self): - """TIME_DATATYPES should be immutable.""" - assert isinstance(TIME_DATATYPES, frozenset) diff --git a/tests/support/debug_handler_mocks.c b/tests/support/debug_handler_mocks.c new file mode 100644 index 00000000..23d5b042 --- /dev/null +++ b/tests/support/debug_handler_mocks.c @@ -0,0 +1,219 @@ +/* + * debug_handler_mocks.c — implementation of the test-side debugger ABI + * fakes. See debug_handler_mocks.h for the contract. + * + * These functions are wired into the runtime by overwriting the + * ext_strucpp_debug_* function pointers (declared extern in + * image_tables.h, defined in image_tables.cpp) and the program-MD5 + * char* pointer (declared in utils.h, defined in utils.c). + * + * Because the runtime declares those externs in C++ (image_tables.cpp) + * and we install from C, the declarations are reproduced here under + * `extern "C"`-equivalent linkage. The installed function pointer + * signatures must match exactly — a mismatch silently corrupts the + * call frame. + */ + +#include "debug_handler_mocks.h" + +#include +#include +#include +#include + +/* The runtime's normal home for these is image_tables.cpp (function + * pointers) and utils.c (md5 char *). image_tables.cpp pulls in the + * full strucpp ABI and a lot of C++ infrastructure that's irrelevant + * to the debugger wire-protocol tests, so we provide the storage here + * in test-support land instead. Ceedling resolves the externs in + * debug_handler.c against these definitions and never compiles + * image_tables.cpp. + * + * scan_counter (referenced by debug_handler.c for the tick field of + * GET / GET_LIST responses) is owned by utils.c — that file is small + * and gets pulled in normally. + * + * If a future test ever wants the real image_tables.cpp definitions, + * gate this block with #ifndef MOCK_DEBUG_OWNS_EXTERNS or split it + * into a separate support file. */ +uint8_t (*ext_strucpp_debug_array_count)(void) = NULL; +uint16_t (*ext_strucpp_debug_elem_count) (uint8_t) = NULL; +uint16_t (*ext_strucpp_debug_size) (uint8_t, uint16_t) = NULL; +uint8_t (*ext_strucpp_debug_set) (uint8_t, uint16_t, bool, + const uint8_t *, uint16_t) = NULL; +uint16_t (*ext_strucpp_debug_read) (uint8_t, uint16_t, uint8_t *) = NULL; +uint8_t (*ext_strucpp_debug_write) (uint8_t, uint16_t, + const uint8_t *, uint16_t) = NULL; + +/* ext_strucpp_program_md5 lives in utils.c; just bring the declaration + * in via the public header. */ +extern char *ext_strucpp_program_md5; + +/* ----------------------------------------------------------------------- + * State backing the fakes. Reset between tests. + * --------------------------------------------------------------------- */ + +static uint8_t g_arr_count = 0; +static uint16_t g_elem_counts[MOCK_DEBUG_MAX_ARRAYS] = {0}; +static mock_debug_elem_t g_elems[MOCK_DEBUG_MAX_ARRAYS][MOCK_DEBUG_MAX_ELEMS]; + +static mock_debug_set_capture_t g_last_set = {0}; +static uint8_t g_set_status = 0x7E; /* MB_DEBUG_SUCCESS */ + +/* MD5 storage. We own the buffer so tests can install non-terminated + * strings to exercise the bounded-read fix. The +2 leaves room for an + * optional trailing null. */ +static char g_md5_buf[64 + 2]; +static char *g_md5_ptr = NULL; + +/* ----------------------------------------------------------------------- + * Faked entry points. + * --------------------------------------------------------------------- */ + +static uint8_t fake_array_count(void) +{ + return g_arr_count; +} + +static uint16_t fake_elem_count(uint8_t arr) +{ + if (arr >= MOCK_DEBUG_MAX_ARRAYS) return 0; + return g_elem_counts[arr]; +} + +static uint16_t fake_size(uint8_t arr, uint16_t elem) +{ + if (arr >= MOCK_DEBUG_MAX_ARRAYS) return 0; + if (elem >= MOCK_DEBUG_MAX_ELEMS) return 0; + return g_elems[arr][elem].size; +} + +static uint16_t fake_read(uint8_t arr, uint16_t elem, uint8_t *dest) +{ + if (arr >= MOCK_DEBUG_MAX_ARRAYS) return 0; + if (elem >= MOCK_DEBUG_MAX_ELEMS) return 0; + uint16_t sz = g_elems[arr][elem].size; + if (sz == 0 || dest == NULL) return 0; + memcpy(dest, g_elems[arr][elem].bytes, sz); + return sz; +} + +static uint8_t fake_set(uint8_t arr, uint16_t elem, bool forcing, + const uint8_t *bytes, uint16_t len) +{ + g_last_set.called = true; + g_last_set.arr = arr; + g_last_set.elem = elem; + g_last_set.forcing = forcing; + g_last_set.len = len; + if (bytes && len > 0) + { + uint16_t copy_len = len > MOCK_DEBUG_MAX_VARSIZE ? MOCK_DEBUG_MAX_VARSIZE : len; + memcpy(g_last_set.bytes, bytes, copy_len); + } + return g_set_status; +} + +static uint8_t fake_write(uint8_t arr, uint16_t elem, + const uint8_t *bytes, uint16_t len) +{ + g_last_set.called = true; + g_last_set.arr = arr; + g_last_set.elem = elem; + g_last_set.forcing = false; + g_last_set.len = len; + if (bytes && len > 0) + { + uint16_t copy_len = len > MOCK_DEBUG_MAX_VARSIZE ? MOCK_DEBUG_MAX_VARSIZE : len; + memcpy(g_last_set.bytes, bytes, copy_len); + } + return g_set_status; +} + +/* ----------------------------------------------------------------------- + * Public setters / install hook. + * --------------------------------------------------------------------- */ + +void mock_debug_install(void) +{ + ext_strucpp_debug_array_count = fake_array_count; + ext_strucpp_debug_elem_count = fake_elem_count; + ext_strucpp_debug_size = fake_size; + ext_strucpp_debug_set = fake_set; + ext_strucpp_debug_read = fake_read; + ext_strucpp_debug_write = fake_write; +} + +void mock_debug_reset(void) +{ + g_arr_count = 0; + memset(g_elem_counts, 0, sizeof(g_elem_counts)); + memset(g_elems, 0, sizeof(g_elems)); + memset(&g_last_set, 0, sizeof(g_last_set)); + g_set_status = 0x7E; + ext_strucpp_program_md5 = NULL; + g_md5_ptr = NULL; + memset(g_md5_buf, 0, sizeof(g_md5_buf)); +} + +void mock_debug_set_arr_count(uint8_t arr_count) +{ + if (arr_count > MOCK_DEBUG_MAX_ARRAYS) arr_count = MOCK_DEBUG_MAX_ARRAYS; + g_arr_count = arr_count; +} + +void mock_debug_set_elem_count(uint8_t arr, uint16_t elem_count) +{ + if (arr >= MOCK_DEBUG_MAX_ARRAYS) return; + if (elem_count > MOCK_DEBUG_MAX_ELEMS) elem_count = MOCK_DEBUG_MAX_ELEMS; + g_elem_counts[arr] = elem_count; +} + +void mock_debug_set_elem(uint8_t arr, uint16_t elem, + uint16_t size, const uint8_t *bytes) +{ + if (arr >= MOCK_DEBUG_MAX_ARRAYS) return; + if (elem >= MOCK_DEBUG_MAX_ELEMS) return; + if (size > MOCK_DEBUG_MAX_VARSIZE) size = MOCK_DEBUG_MAX_VARSIZE; + g_elems[arr][elem].size = size; + if (bytes && size > 0) + { + memcpy(g_elems[arr][elem].bytes, bytes, size); + } +} + +const mock_debug_set_capture_t *mock_debug_last_set(void) +{ + return &g_last_set; +} + +void mock_debug_program_set_status(uint8_t status) +{ + g_set_status = status; +} + +void mock_debug_set_md5(const char *md5_chars, size_t len, bool terminated) +{ + if (md5_chars == NULL) + { + ext_strucpp_program_md5 = NULL; + g_md5_ptr = NULL; + return; + } + if (len > sizeof(g_md5_buf) - 1) len = sizeof(g_md5_buf) - 1; + memcpy(g_md5_buf, md5_chars, len); + if (terminated) + { + g_md5_buf[len] = '\0'; + } + else + { + /* Sentinel byte that should NOT be read by the runtime if the + * MD5_HEX_LEN bound is honoured. If the runtime reads past the + * end despite the bound, this byte ends up in the response and + * the test catches it. */ + g_md5_buf[len] = (char)0xAA; + } + g_md5_ptr = g_md5_buf; + ext_strucpp_program_md5 = g_md5_ptr; +} diff --git a/tests/support/debug_handler_mocks.h b/tests/support/debug_handler_mocks.h new file mode 100644 index 00000000..bae5d558 --- /dev/null +++ b/tests/support/debug_handler_mocks.h @@ -0,0 +1,82 @@ +/* + * debug_handler_mocks.h — controllable fakes for the strucpp debugger ABI. + * + * The runtime resolves these function pointers from the loaded program .so + * at start time (image_tables.cpp:symbols_init). For tests, we install + * fakes whose behavior can be programmed per-test via the `mock_debug_*` + * setters: array layout, per-element bytes, write capture, and forced + * "out of range" returns from the .so side so we can verify the runtime + * gate doesn't depend on cooperation from the .so. + * + * Tests opt into the fakes via mock_debug_install() (typically in setUp). + * mock_debug_reset() returns the table to a canonical empty state without + * tearing down the function pointers, so each test sees a fresh slate. + */ + +#ifndef TESTS_SUPPORT_DEBUG_HANDLER_MOCKS_H +#define TESTS_SUPPORT_DEBUG_HANDLER_MOCKS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Maximum mockable arrays / elems per array. Generous enough for all + * tests; tighter than the on-wire max so accidental bugs surface fast. */ +#define MOCK_DEBUG_MAX_ARRAYS 16 +#define MOCK_DEBUG_MAX_ELEMS 64 +#define MOCK_DEBUG_MAX_VARSIZE 32 + +typedef struct +{ + /* If non-zero, debug_size() returns this for the elem; otherwise 0. */ + uint16_t size; + /* Bytes that debug_read() will copy into dest[]. */ + uint8_t bytes[MOCK_DEBUG_MAX_VARSIZE]; +} mock_debug_elem_t; + +/* Last-seen arguments to debug_set / debug_write so tests can assert. */ +typedef struct +{ + bool called; + uint8_t arr; + uint16_t elem; + bool forcing; + uint8_t bytes[MOCK_DEBUG_MAX_VARSIZE]; + uint16_t len; + /* Programmed return value; 0x7E (MB_DEBUG_SUCCESS) by default. */ + uint8_t return_status; +} mock_debug_set_capture_t; + +/* Install function pointers into the runtime's externs. Idempotent. */ +void mock_debug_install(void); + +/* Clear all programmed state. Does NOT uninstall the function pointers. */ +void mock_debug_reset(void); + +/* Configure the array layout. arr_count <= MOCK_DEBUG_MAX_ARRAYS. */ +void mock_debug_set_arr_count(uint8_t arr_count); +void mock_debug_set_elem_count(uint8_t arr, uint16_t elem_count); +void mock_debug_set_elem(uint8_t arr, uint16_t elem, + uint16_t size, const uint8_t *bytes); + +/* Read out the most recent debug_set() / debug_write() invocation. */ +const mock_debug_set_capture_t *mock_debug_last_set(void); + +/* Override the return value of the next debug_set() / debug_write() call. */ +void mock_debug_program_set_status(uint8_t status); + +/* Install (or clear) the program MD5 string. NULL clears the pointer + * entirely so tests can assert the "not loaded" branch. The + * `terminated` flag controls whether a trailing null byte is written — + * tests that exercise the unbounded-read mitigation set this to false.*/ +void mock_debug_set_md5(const char *md5_chars, size_t len, bool terminated); + +#ifdef __cplusplus +} +#endif + +#endif /* TESTS_SUPPORT_DEBUG_HANDLER_MOCKS_H */ diff --git a/tests/support/plugin_driver_stubs.c b/tests/support/plugin_driver_stubs.c index 16aa5d9b..fbc902d6 100644 --- a/tests/support/plugin_driver_stubs.c +++ b/tests/support/plugin_driver_stubs.c @@ -1,17 +1,17 @@ #include "plugin_config.h" #include "plugin_driver.h" #include "journal_buffer.h" -#include "plugin_utils.h" #include #include #include #include -// Stub: ext_common_ticktime__ (utils.c) -- the runtime publishes the PLC -// scan tick interval here. Plugin drivers (plugin_driver.c) read it during -// arg construction, so a NULL stub is enough for the unit tests. -unsigned long long *ext_common_ticktime__ = NULL; +// Stub: base_tick_ns (utils.c) -- the runtime stores the PLC scan tick +// interval here (GCD of declared task intervals). Plugin drivers +// (plugin_driver.c) read it during arg construction, so a default +// stub value is enough for the unit tests. +uint64_t base_tick_ns = 0; // Stub implementations for external buffer variables (image_tables.c) IEC_BOOL *bool_input[BUFFER_SIZE][8]; @@ -89,24 +89,20 @@ int journal_write_lint(journal_buffer_type_t type, uint16_t index, return 0; } -// Stub: get_var_* (plugin_utils.c) -void get_var_list(size_t num_vars, size_t *indexes, void **result) -{ - (void)num_vars; - (void)indexes; - (void)result; -} - -size_t get_var_size(size_t idx) -{ - (void)idx; - return 0; -} - -uint16_t get_var_count(void) -{ - return 0; -} +// The MatIEC-era flat-index API (get_var_list / get_var_size / +// get_var_count from plugin_utils.c) was removed alongside the rest of +// the MatIEC pipeline. Plugins now receive structured runtime args +// (plugin_runtime_args_t) constructed from the STruC++ debug map; the +// debugger ABI is exercised in test_debug_handler.c. + +// Stubs: plc_tasks_reader_lock / plc_tasks_reader_unlock (plc_state_manager.cpp). +// scan_cycle_manager.c calls these around format_timing_stats_response to +// keep the reader from racing the bootstrap thread freeing plc_tasks. The +// real lock lives in plc_state_manager.cpp; tests don't pull that .cpp in, +// so we provide no-op stubs. Tests that exercise the lifecycle (rather +// than just the per-tracker math) will need to link the real symbols. +void plc_tasks_reader_lock(void) {} +void plc_tasks_reader_unlock(void) {} // Stub: log_* (log.c) void log_info(const char *fmt, ...) diff --git a/tests/test_debug_handler.c b/tests/test_debug_handler.c new file mode 100644 index 00000000..e8b31ff7 --- /dev/null +++ b/tests/test_debug_handler.c @@ -0,0 +1,504 @@ +/* + * test_debug_handler.c — wire-level tests for the new STruC++ debugger + * ABI (FC 0x41-0x45) at the `process_debug_data` boundary. + * + * Replaces the MatIEC-era flat-index API tests (get_var_list / + * get_var_size / get_var_count) deleted with the runtime cleanup. Goal + * is to lock the on-wire frame format (mirrored by the editor's debug + * client at src/frontend/utils/debug-parser.ts and the Arduino + * StrucppBaremetal/ModbusSlave.cpp), and to pin the defensive bounds + * checks the runtime added on top of the .so's internal validation. + * + * Tests use the mock debugger ABI in tests/support/debug_handler_mocks.* + * to drive controlled `arr_count` / `elem_count` / `read` / `set` + * behavior. process_debug_data is called directly with a constructed + * frame; the response is unpacked and compared against expected bytes. + */ + +#include "debug_handler.h" +#include "debug_handler_mocks.h" +#include "unity.h" + +#include +#include +#include + +/* Declared (not defined) here so the "symbols not loaded" test can + * NULL it out without dragging image_tables.h's full C++ surface in. + * Storage is in tests/support/debug_handler_mocks.c. */ +extern uint8_t (*ext_strucpp_debug_array_count)(void); + +#define MB_FC_DEBUG_INFO 0x41 +#define MB_FC_DEBUG_SET 0x42 +#define MB_FC_DEBUG_GET 0x43 +#define MB_FC_DEBUG_GET_LIST 0x44 +#define MB_FC_DEBUG_GET_MD5 0x45 + +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_NOT_LOADED 0x83 + +/* Wire frame buffer. Sized comfortably within the runtime's + * MAX_DEBUG_FRAME = 4096 limit. */ +static uint8_t frame[4096]; + +/* ----- Helpers ---------------------------------------------------------- */ + +static uint16_t read_u16_be(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); +} + +static uint32_t read_u32_be(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} + +static void write_u16_be(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)(v & 0xFF); +} + +void setUp(void) +{ + mock_debug_install(); + mock_debug_reset(); + memset(frame, 0, sizeof(frame)); +} + +void tearDown(void) +{ + mock_debug_reset(); +} + +/* ======================================================================= + * FC 0x41 — DEBUG_INFO + * ======================================================================= */ + +void test_debug_info_returns_array_layout(void) +{ + mock_debug_set_arr_count(3); + mock_debug_set_elem_count(0, 5); + mock_debug_set_elem_count(1, 10); + mock_debug_set_elem_count(2, 7); + + frame[0] = MB_FC_DEBUG_INFO; + size_t len = process_debug_data(frame, 1); + + /* Expected layout: [FC][arr_count][status][u16 BE per array] */ + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_INFO, frame[0]); + TEST_ASSERT_EQUAL_UINT8(3, frame[1]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[2]); + TEST_ASSERT_EQUAL_UINT16(5, read_u16_be(&frame[3])); + TEST_ASSERT_EQUAL_UINT16(10, read_u16_be(&frame[5])); + TEST_ASSERT_EQUAL_UINT16(7, read_u16_be(&frame[7])); + TEST_ASSERT_EQUAL_size_t(9, len); +} + +void test_debug_info_when_symbols_not_loaded_returns_not_loaded(void) +{ + /* Don't install — leave function pointers NULL */ + ext_strucpp_debug_array_count = NULL; + + frame[0] = MB_FC_DEBUG_INFO; + size_t len = process_debug_data(frame, 1); + + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_INFO, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_NOT_LOADED, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); + + /* Restore so subsequent tests aren't broken by tearDown ordering */ + mock_debug_install(); +} + +/* ======================================================================= + * FC 0x42 — DEBUG_SET (force / unforce) + * ======================================================================= */ + +void test_debug_set_force_passes_through_to_so(void) +{ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + /* Frame: [FC][arr][elem hi][elem lo][force][len hi][len lo][value...] */ + frame[0] = MB_FC_DEBUG_SET; + frame[1] = 0; /* arr */ + write_u16_be(&frame[2], 2); /* elem */ + frame[4] = 1; /* forcing = true */ + write_u16_be(&frame[5], 2); /* val_len = 2 */ + frame[7] = 0xCA; + frame[8] = 0xFE; + + size_t len = process_debug_data(frame, 9); + + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_SET, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); + + const mock_debug_set_capture_t *cap = mock_debug_last_set(); + TEST_ASSERT_TRUE(cap->called); + TEST_ASSERT_EQUAL_UINT8(0, cap->arr); + TEST_ASSERT_EQUAL_UINT16(2, cap->elem); + TEST_ASSERT_TRUE(cap->forcing); + TEST_ASSERT_EQUAL_UINT16(2, cap->len); + TEST_ASSERT_EQUAL_UINT8(0xCA, cap->bytes[0]); + TEST_ASSERT_EQUAL_UINT8(0xFE, cap->bytes[1]); +} + +void test_debug_set_unforce_clears_forcing_flag(void) +{ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + frame[0] = MB_FC_DEBUG_SET; + frame[1] = 0; + write_u16_be(&frame[2], 0); + frame[4] = 0; /* forcing = false → unforce */ + write_u16_be(&frame[5], 0); + + size_t len = process_debug_data(frame, 7); + TEST_ASSERT_EQUAL_size_t(2, len); + + const mock_debug_set_capture_t *cap = mock_debug_last_set(); + TEST_ASSERT_TRUE(cap->called); + TEST_ASSERT_FALSE(cap->forcing); + TEST_ASSERT_EQUAL_UINT16(0, cap->len); +} + +void test_debug_set_rejects_oob_arr_at_runtime_gate(void) +{ + /* Configures the .so as having ONE array. The wire request asks + * to set arr=5 — way out of range. Without the runtime gate (the + * fix for review issue #17), this would call into the .so's + * debug_set with an OOB arr index and rely on the .so to validate. + * With the gate, the runtime returns OUT_OF_BOUNDS without ever + * dispatching. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + frame[0] = MB_FC_DEBUG_SET; + frame[1] = 5; /* arr — OOB */ + write_u16_be(&frame[2], 0); + frame[4] = 1; + write_u16_be(&frame[5], 0); + + size_t len = process_debug_data(frame, 7); + + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_SET, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_OUT_OF_BOUNDS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); + /* The .so must NOT have been called. */ + TEST_ASSERT_FALSE(mock_debug_last_set()->called); +} + +void test_debug_set_rejects_truncated_frame(void) +{ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + /* val_len declares 4 bytes but the frame only carries 1 (length + * argument to process_debug_data is 8, body would need 11). */ + frame[0] = MB_FC_DEBUG_SET; + frame[1] = 0; + write_u16_be(&frame[2], 0); + frame[4] = 1; + write_u16_be(&frame[5], 4); + frame[7] = 0xAA; + + size_t len = process_debug_data(frame, 8); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_OUT_OF_BOUNDS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); + TEST_ASSERT_FALSE(mock_debug_last_set()->called); +} + +/* ======================================================================= + * FC 0x43 — DEBUG_GET (read a contiguous range) + * ======================================================================= */ + +void test_debug_get_packs_contiguous_range(void) +{ + /* arr=0 has 4 elements, sizes 2/2/4/2 with known bytes. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + uint8_t e0[] = {0x01, 0x02}; + uint8_t e1[] = {0x03, 0x04}; + uint8_t e2[] = {0x05, 0x06, 0x07, 0x08}; + uint8_t e3[] = {0x09, 0x0A}; + mock_debug_set_elem(0, 0, sizeof e0, e0); + mock_debug_set_elem(0, 1, sizeof e1, e1); + mock_debug_set_elem(0, 2, sizeof e2, e2); + mock_debug_set_elem(0, 3, sizeof e3, e3); + + frame[0] = MB_FC_DEBUG_GET; + frame[1] = 0; /* arr */ + write_u16_be(&frame[2], 0); /* start */ + write_u16_be(&frame[4], 3); /* end inclusive */ + + size_t len = process_debug_data(frame, 6); + + /* Header layout: [FC][STATUS][last_elem u16][tick u32][resp_size u16][data...] */ + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_GET, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[1]); + TEST_ASSERT_EQUAL_UINT16(3, read_u16_be(&frame[2])); + TEST_ASSERT_EQUAL_UINT16(2 + 2 + 4 + 2, read_u16_be(&frame[8])); + /* Body bytes back-to-back from element 0 → 3. */ + TEST_ASSERT_EQUAL_UINT8(0x01, frame[10]); + TEST_ASSERT_EQUAL_UINT8(0x02, frame[11]); + TEST_ASSERT_EQUAL_UINT8(0x03, frame[12]); + TEST_ASSERT_EQUAL_UINT8(0x04, frame[13]); + TEST_ASSERT_EQUAL_UINT8(0x05, frame[14]); + TEST_ASSERT_EQUAL_UINT8(0x06, frame[15]); + TEST_ASSERT_EQUAL_UINT8(0x07, frame[16]); + TEST_ASSERT_EQUAL_UINT8(0x08, frame[17]); + TEST_ASSERT_EQUAL_UINT8(0x09, frame[18]); + TEST_ASSERT_EQUAL_UINT8(0x0A, frame[19]); + TEST_ASSERT_EQUAL_size_t(20, len); +} + +void test_debug_get_rejects_oob_arr_at_runtime_gate(void) +{ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + frame[0] = MB_FC_DEBUG_GET; + frame[1] = 250; /* arr — OOB */ + write_u16_be(&frame[2], 0); + write_u16_be(&frame[4], 0); + + size_t len = process_debug_data(frame, 6); + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_GET, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_OUT_OF_BOUNDS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); +} + +void test_debug_get_rejects_inverted_range(void) +{ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 4); + + frame[0] = MB_FC_DEBUG_GET; + frame[1] = 0; + write_u16_be(&frame[2], 3); /* start > end */ + write_u16_be(&frame[4], 1); + + size_t len = process_debug_data(frame, 6); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_OUT_OF_BOUNDS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); +} + +/* ======================================================================= + * FC 0x44 — DEBUG_GET_LIST (cross-array batch read) + * ======================================================================= */ + +void test_debug_get_list_packs_multiple_arrays(void) +{ + mock_debug_set_arr_count(2); + mock_debug_set_elem_count(0, 3); + mock_debug_set_elem_count(1, 3); + uint8_t a[] = {0xAA, 0xBB}; + uint8_t b[] = {0xCC}; + uint8_t c[] = {0xDD, 0xEE, 0xFF}; + mock_debug_set_elem(0, 1, sizeof a, a); + mock_debug_set_elem(1, 0, sizeof b, b); + mock_debug_set_elem(1, 2, sizeof c, c); + + /* Request: 3 entries (arr,elem) — each 3 bytes. */ + frame[0] = MB_FC_DEBUG_GET_LIST; + write_u16_be(&frame[1], 3); + /* (0, 1) */ + frame[3] = 0; + write_u16_be(&frame[4], 1); + /* (1, 0) */ + frame[6] = 1; + write_u16_be(&frame[7], 0); + /* (1, 2) */ + frame[9] = 1; + write_u16_be(&frame[10], 2); + + size_t len = process_debug_data(frame, 12); + + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_GET_LIST, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[1]); + TEST_ASSERT_EQUAL_UINT16(2, read_u16_be(&frame[2])); /* last_req_idx */ + TEST_ASSERT_EQUAL_UINT16(2 + 1 + 3, read_u16_be(&frame[8])); + TEST_ASSERT_EQUAL_UINT8(0xAA, frame[10]); + TEST_ASSERT_EQUAL_UINT8(0xBB, frame[11]); + TEST_ASSERT_EQUAL_UINT8(0xCC, frame[12]); + TEST_ASSERT_EQUAL_UINT8(0xDD, frame[13]); + TEST_ASSERT_EQUAL_UINT8(0xEE, frame[14]); + TEST_ASSERT_EQUAL_UINT8(0xFF, frame[15]); + TEST_ASSERT_EQUAL_size_t(16, len); +} + +void test_debug_get_list_skips_oob_arr_entries(void) +{ + /* The runtime should silently advance past entries with OOB arr + * (instead of dispatching them to the .so) — defense-in-depth for + * a malformed batch request. The frame's last_req_idx still + * advances, so the editor can resume from i+1. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 2); + uint8_t v[] = {0x42}; + mock_debug_set_elem(0, 1, sizeof v, v); + + frame[0] = MB_FC_DEBUG_GET_LIST; + write_u16_be(&frame[1], 2); + /* (255, 0) → OOB; must be skipped */ + frame[3] = 255; + write_u16_be(&frame[4], 0); + /* (0, 1) → valid */ + frame[6] = 0; + write_u16_be(&frame[7], 1); + + size_t len = process_debug_data(frame, 9); + + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[1]); + TEST_ASSERT_EQUAL_UINT16(1, read_u16_be(&frame[2])); /* last_req_idx */ + TEST_ASSERT_EQUAL_UINT16(1, read_u16_be(&frame[8])); + TEST_ASSERT_EQUAL_UINT8(0x42, frame[10]); + TEST_ASSERT_EQUAL_size_t(11, len); +} + +/* ======================================================================= + * FC 0x45 — DEBUG_GET_MD5 (with endianness probe echo) + * ======================================================================= */ + +void test_debug_get_md5_returns_string_with_echo_bytes(void) +{ + const char md5[] = "0123456789abcdef0123456789abcdef"; + mock_debug_set_md5(md5, 32, true); + + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = 0xDE; /* echo hi */ + frame[2] = 0xAD; /* echo lo */ + + size_t len = process_debug_data(frame, 3); + + /* Expected: [FC][STATUS][md5...32][echo hi][echo lo] = 36 bytes */ + TEST_ASSERT_EQUAL_UINT8(MB_FC_DEBUG_GET_MD5, frame[0]); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_SUCCESS, frame[1]); + TEST_ASSERT_EQUAL_MEMORY(md5, &frame[2], 32); + TEST_ASSERT_EQUAL_UINT8(0xDE, frame[34]); + TEST_ASSERT_EQUAL_UINT8(0xAD, frame[35]); + TEST_ASSERT_EQUAL_size_t(36, len); +} + +void test_debug_get_md5_when_symbol_missing_returns_not_loaded(void) +{ + /* No mock_debug_set_md5() call — pointer stays NULL */ + + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = 0xDE; + frame[2] = 0xAD; + + size_t len = process_debug_data(frame, 3); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_NOT_LOADED, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); +} + +void test_debug_get_md5_bounds_unterminated_string(void) +{ + /* Validates the fix for review issue #18: a malformed .so exporting + * a non-null-terminated strucpp_program_md5 must not cause an + * unbounded read off the end of the .so's .rodata. The length + * cap is MD5_HEX_LEN (32 bytes) regardless of termination. */ + char raw[33]; + memset(raw, 'X', 32); + raw[32] = '\0'; /* placeholder; mock will overwrite */ + mock_debug_set_md5(raw, 32, /* terminated */ false); + + frame[0] = MB_FC_DEBUG_GET_MD5; + frame[1] = 0x12; + frame[2] = 0x34; + + size_t len = process_debug_data(frame, 3); + + /* Exactly 32 bytes of payload should be copied — no more, no less. */ + TEST_ASSERT_EQUAL_size_t(36, len); + /* All 32 payload bytes are 'X'; sentinel 0xAA installed by the mock + * past the 32-byte boundary must NOT appear in the response. */ + for (size_t i = 0; i < 32; ++i) + { + TEST_ASSERT_EQUAL_UINT8('X', frame[2 + i]); + } + TEST_ASSERT_EQUAL_UINT8(0x12, frame[34]); + TEST_ASSERT_EQUAL_UINT8(0x34, frame[35]); +} + +/* ======================================================================= + * Frame dispatcher — unknown FC and short frames + * ======================================================================= */ + +void test_unknown_function_code_returns_zero_length(void) +{ + /* Any FC outside 0x41-0x45 should be rejected by the dispatcher. */ + frame[0] = 0xFF; + size_t len = process_debug_data(frame, 1); + TEST_ASSERT_EQUAL_size_t(0, len); +} + +void test_zero_length_frame_returns_zero(void) +{ + size_t len = process_debug_data(frame, 0); + TEST_ASSERT_EQUAL_size_t(0, len); +} + +void test_get_list_zero_entries_returns_oob(void) +{ + /* num_indexes = 0 is a malformed batch; the runtime rejects it + * up front rather than emitting an empty body. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 1); + + frame[0] = MB_FC_DEBUG_GET_LIST; + write_u16_be(&frame[1], 0); + + size_t len = process_debug_data(frame, 3); + TEST_ASSERT_EQUAL_UINT8(MB_DEBUG_ERROR_OUT_OF_BOUNDS, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); +} + +void test_get_unknown_status_propagated_from_so(void) +{ + /* The runtime forwards whatever status byte the .so returns from + * debug_set, even if it's a non-canonical value — the editor + * decides how to surface it. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 1); + mock_debug_program_set_status(0xC1); /* arbitrary non-success */ + + frame[0] = MB_FC_DEBUG_SET; + frame[1] = 0; + write_u16_be(&frame[2], 0); + frame[4] = 1; + write_u16_be(&frame[5], 1); + frame[7] = 0xAA; + + size_t len = process_debug_data(frame, 8); + TEST_ASSERT_EQUAL_UINT8(0xC1, frame[1]); + TEST_ASSERT_EQUAL_size_t(2, len); +} + +void test_response_tick_field_is_present(void) +{ + /* The DEBUG_GET response carries the runtime's scan_counter at + * frame[4..7] (u32 BE). Tests don't bump scan_counter, so we just + * assert the field exists and is readable — it's a wire format + * regression guard, not a value test. */ + mock_debug_set_arr_count(1); + mock_debug_set_elem_count(0, 1); + uint8_t v[] = {0x42}; + mock_debug_set_elem(0, 0, sizeof v, v); + + frame[0] = MB_FC_DEBUG_GET; + frame[1] = 0; + write_u16_be(&frame[2], 0); + write_u16_be(&frame[4], 0); + + size_t len = process_debug_data(frame, 6); + TEST_ASSERT_EQUAL_size_t(11, len); + /* Tick field is in the response, value not asserted. */ + (void)read_u32_be(&frame[4]); +} diff --git a/tests/test_ethercat_config_helpers.c b/tests/test_ethercat_config_helpers.c index 7391478f..888d57dd 100644 --- a/tests/test_ethercat_config_helpers.c +++ b/tests/test_ethercat_config_helpers.c @@ -36,8 +36,8 @@ void test_init_defaults_MasterFields_ShouldHaveExpectedDefaults(void) 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(0, config.master.task_name[0]); - TEST_ASSERT_EQUAL_INT(0, config.master.task_cycle_time_us); + TEST_ASSERT_EQUAL_INT(90, config.master.task_priority); + TEST_ASSERT_TRUE(config.master.safe_close); } void test_init_defaults_DiagnosticsFields_ShouldHaveExpectedDefaults(void) diff --git a/tests/test_scan_cycle_tracker.c b/tests/test_scan_cycle_tracker.c new file mode 100644 index 00000000..58a3bec3 --- /dev/null +++ b/tests/test_scan_cycle_tracker.c @@ -0,0 +1,268 @@ +/* + * test_scan_cycle_tracker.c — unit tests for the per-task scan-cycle + * tracker introduced alongside the multi-task refactor. + * + * Replaces the previous global-stats path (single fastest-task counters + * shared across all threads). Each task now owns its own tracker and the + * STATS handler walks plc_tasks[] to emit one entry per task. + * + * The behaviour we lock here: + * - first call to scan_cycle_tracker_start() seeds anchors WITHOUT + * emitting cycle-time / latency stats (those need a baseline); + * - second and subsequent starts compute cycle_time = now - + * last_start, latency = now - expected_start; + * - scan_cycle_tracker_end() captures scan_time = now - last_start + * and increments `overruns` if we ran past the projected next-wakeup; + * - scan_cycle_tracker_snapshot() returns false until at least one + * scan completes, then returns the tracker's stats with the avg + * fields recovered from the EWMA sum/avg_window. + * + * The EWMA window is computed as EWMA_TARGET_WINDOW_US / interval_us + * (clamped to >= 1). Tests that pin specific `avg` values choose + * intervals that produce avg_window=1, so a single sample IS the average + * — that side-steps the cold-start ramp the reviewer flagged in #16, + * which is intended behaviour. + */ + +#include "scan_cycle_manager.h" +#include "unity.h" + +#include +#include +#include + +/* Allow the test to drive elapsed time deterministically. The tracker + * uses CLOCK_MONOTONIC under the hood; all assertions compare against + * elapsed durations rather than absolute timestamps to ride over real + * clock noise. */ + +static scan_cycle_tracker_t tracker; + +void setUp(void) +{ + memset(&tracker, 0, sizeof(tracker)); +} + +void tearDown(void) +{ + scan_cycle_tracker_cleanup(&tracker); +} + +/* Busy-wait for at least `us` microseconds of CLOCK_MONOTONIC time. + * Tests use this to control the gap between start/end calls without + * pulling in the full mock-clock infrastructure ceedling would need. */ +static void busy_sleep_us(uint64_t us) +{ + struct timespec t0, now; + clock_gettime(CLOCK_MONOTONIC, &t0); + do + { + clock_gettime(CLOCK_MONOTONIC, &now); + uint64_t dt = (uint64_t)(now.tv_sec - t0.tv_sec) * 1000000ull + + (uint64_t)(now.tv_nsec - t0.tv_nsec) / 1000ull; + if (dt >= us) break; + } while (1); +} + +/* ---------------------------------------------------------------------- + * Initialisation and validation + * -------------------------------------------------------------------- */ + +void test_init_rejects_null(void) +{ + TEST_ASSERT_EQUAL(-1, scan_cycle_tracker_init(NULL, 1000000)); +} + +void test_init_seeds_min_to_int64_max(void) +{ + /* On a fresh tracker, min fields must start at INT64_MAX so the + * first observation always wins the min comparison — otherwise + * min would be reported as 0 forever. */ + TEST_ASSERT_EQUAL(0, scan_cycle_tracker_init(&tracker, 1000000)); + TEST_ASSERT_EQUAL_INT64(INT64_MAX, tracker.stats.scan_time_min); + TEST_ASSERT_EQUAL_INT64(INT64_MAX, tracker.stats.cycle_time_min); + TEST_ASSERT_EQUAL_INT64(INT64_MAX, tracker.stats.cycle_latency_min); +} + +void test_init_clamps_avg_window_to_at_least_one(void) +{ + /* interval_ns = 0 would normally yield interval_us = 0 and a + * divide-by-zero in the avg_window calculation. The clamp catches + * that and drops to 1 (single-sample window). */ + TEST_ASSERT_EQUAL(0, scan_cycle_tracker_init(&tracker, 0)); + TEST_ASSERT_EQUAL_INT64(1, tracker.avg_window); +} + +void test_init_avg_window_matches_target_for_1ms_cycle(void) +{ + /* 1 ms cycle, target window 2 s → 2000 samples. */ + TEST_ASSERT_EQUAL(0, scan_cycle_tracker_init(&tracker, 1000000)); + TEST_ASSERT_EQUAL_INT64(2000, tracker.avg_window); +} + +void test_init_avg_window_matches_target_for_100ms_cycle(void) +{ + /* 100 ms cycle, target window 2 s → 20 samples. */ + TEST_ASSERT_EQUAL(0, scan_cycle_tracker_init(&tracker, 100000000)); + TEST_ASSERT_EQUAL_INT64(20, tracker.avg_window); +} + +/* ---------------------------------------------------------------------- + * First-cycle seeding behaviour + * -------------------------------------------------------------------- */ + +void test_first_start_only_seeds_no_stats_emitted(void) +{ + /* The first call to start() lays down anchors but cannot compute + * cycle_time or latency (no prior reference). After it returns, + * scan_count is 1 but stats aren't meaningful — snapshot returns + * true once scan_count > 0, but min fields stay at INT64_MAX + * until the second cycle observes something. */ + scan_cycle_tracker_init(&tracker, 1000000); + scan_cycle_tracker_start(&tracker); + + TEST_ASSERT_EQUAL_INT64(1, tracker.stats.scan_count); + /* Did NOT touch cycle_time_min — first cycle has no baseline */ + TEST_ASSERT_EQUAL_INT64(INT64_MAX, tracker.stats.cycle_time_min); + TEST_ASSERT_EQUAL_INT64(INT64_MAX, tracker.stats.cycle_latency_min); + TEST_ASSERT_NOT_EQUAL(0, tracker.last_start_us); + TEST_ASSERT_NOT_EQUAL(0, tracker.expected_start_us); +} + +void test_snapshot_returns_false_before_first_scan(void) +{ + /* scan_count starts at 0; snapshot's "valid" predicate is + * scan_count > 0. Important for the JSON path: we emit nulls for + * pre-first-scan trackers instead of bogus zero stats. */ + scan_cycle_tracker_init(&tracker, 1000000); + plc_timing_stats_t out = {0}; + bool valid = scan_cycle_tracker_snapshot(&tracker, &out); + TEST_ASSERT_FALSE(valid); +} + +void test_snapshot_returns_true_after_first_start(void) +{ + scan_cycle_tracker_init(&tracker, 1000000); + scan_cycle_tracker_start(&tracker); + plc_timing_stats_t out = {0}; + bool valid = scan_cycle_tracker_snapshot(&tracker, &out); + TEST_ASSERT_TRUE(valid); + TEST_ASSERT_EQUAL_INT64(1, out.scan_count); +} + +void test_snapshot_rejects_null_args(void) +{ + plc_timing_stats_t out = {0}; + TEST_ASSERT_FALSE(scan_cycle_tracker_snapshot(NULL, &out)); + + scan_cycle_tracker_init(&tracker, 1000000); + TEST_ASSERT_FALSE(scan_cycle_tracker_snapshot(&tracker, NULL)); +} + +/* ---------------------------------------------------------------------- + * Per-cycle metric capture + * -------------------------------------------------------------------- */ + +void test_scan_time_captured_between_start_and_end(void) +{ + scan_cycle_tracker_init(&tracker, 1000000); + scan_cycle_tracker_start(&tracker); + busy_sleep_us(2000); /* ~2 ms scan body */ + scan_cycle_tracker_end(&tracker); + + /* scan_time_min is updated by end(). It should reflect ~2 ms. */ + TEST_ASSERT_GREATER_OR_EQUAL_INT64(1500, tracker.stats.scan_time_min); + /* Allow a generous upper bound for CI / scheduler noise. */ + TEST_ASSERT_LESS_OR_EQUAL_INT64(50000, tracker.stats.scan_time_max); +} + +void test_cycle_time_captured_between_consecutive_starts(void) +{ + /* cycle_time = elapsed since previous start, computed on the + * SECOND start() call onward. We sleep enough to dominate + * scheduler noise. */ + scan_cycle_tracker_init(&tracker, 1000000); + scan_cycle_tracker_start(&tracker); /* seed */ + busy_sleep_us(3000); + scan_cycle_tracker_end(&tracker); + busy_sleep_us(2000); + scan_cycle_tracker_start(&tracker); /* now we measure */ + + TEST_ASSERT_GREATER_OR_EQUAL_INT64(4000, tracker.stats.cycle_time_min); + TEST_ASSERT_EQUAL_INT64(2, tracker.stats.scan_count); +} + +void test_overruns_increment_when_scan_runs_past_deadline(void) +{ + /* Tight 1 ms interval, sleep 5 ms inside the scan body — the end() + * must observe now > expected_start and bump overruns. */ + scan_cycle_tracker_init(&tracker, 1000000); + scan_cycle_tracker_start(&tracker); + busy_sleep_us(5000); + scan_cycle_tracker_end(&tracker); + + TEST_ASSERT_GREATER_OR_EQUAL_INT64(1, tracker.stats.overruns); +} + +void test_no_overrun_when_scan_finishes_within_period(void) +{ + /* 100 ms interval, sleep 1 ms inside scan: well within budget. */ + scan_cycle_tracker_init(&tracker, 100000000); + scan_cycle_tracker_start(&tracker); + busy_sleep_us(1000); + scan_cycle_tracker_end(&tracker); + + TEST_ASSERT_EQUAL_INT64(0, tracker.stats.overruns); +} + +/* ---------------------------------------------------------------------- + * EWMA recovery + * -------------------------------------------------------------------- */ + +void test_avg_recovers_single_sample_when_avg_window_is_one(void) +{ + /* avg_window=1 makes the EWMA collapse to "the latest sample IS + * the average". interval_ns = EWMA_TARGET_WINDOW_US * 1000 makes + * the calculation interval_us / EWMA_TARGET_WINDOW_US = 1 sample. + * + * This sidesteps the cold-start ramp the reviewer flagged in #16 + * — at avg_window=1 there is no ramp. + * + * 2 s = 2_000_000 us → interval_ns = 2_000_000_000 (2 s cycle). + */ + scan_cycle_tracker_init(&tracker, 2000000000LL); + TEST_ASSERT_EQUAL_INT64(1, tracker.avg_window); + + scan_cycle_tracker_start(&tracker); + busy_sleep_us(1500); + scan_cycle_tracker_end(&tracker); + + plc_timing_stats_t out = {0}; + TEST_ASSERT_TRUE(scan_cycle_tracker_snapshot(&tracker, &out)); + + /* scan_time_avg should equal the single observed sample (within + * the ~us-noise floor of clock_gettime + busy-sleep). */ + TEST_ASSERT_GREATER_OR_EQUAL_INT64(1000, out.scan_time_avg); + TEST_ASSERT_LESS_OR_EQUAL_INT64(50000, out.scan_time_avg); +} + +/* ---------------------------------------------------------------------- + * NULL / cleanup safety + * -------------------------------------------------------------------- */ + +void test_start_end_null_safe(void) +{ + /* All public entry points must be NULL-safe — task threads call + * these inside a tight loop and crashing on a partially-constructed + * tracker would be worse than a no-op. */ + scan_cycle_tracker_start(NULL); + scan_cycle_tracker_end(NULL); + /* Reaching here means no segfault. */ + TEST_PASS(); +} + +void test_cleanup_null_safe(void) +{ + scan_cycle_tracker_cleanup(NULL); + TEST_PASS(); +} diff --git a/webserver/app.py b/webserver/app.py index eeec0a0f..39c1cd53 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -32,6 +32,7 @@ build_state, run_compile, safe_extract, + apply_vpp_plugin_conf, update_plugin_configurations, ) from webserver.restapi import ( @@ -241,16 +242,25 @@ def handle_upload_file(data: dict) -> dict: safe_extract(zip_file, extract_dir, valid_files) - # Update plugin configurations based on extracted config files + # Apply VPP plugin conf from upload (copy if present, delete if not) + apply_vpp_plugin_conf(extract_dir) + + # Update built-in plugin configurations based on extracted config files update_plugin_configurations(extract_dir) + # ?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 + # don't pass this flag, so behaviour for them is unchanged. + clean_build = flask.request.args.get("clean") == "1" + # Start compilation in a separate thread build_state.status = BuildStatus.COMPILING task_compile = threading.Thread( target=run_compile, args=(runtime_manager,), - kwargs={"cwd": extract_dir}, + kwargs={"cwd": extract_dir, "clean": clean_build}, daemon=True, ) diff --git a/webserver/plcapp_management.py b/webserver/plcapp_management.py index a512c504..68e63797 100644 --- a/webserver/plcapp_management.py +++ b/webserver/plcapp_management.py @@ -1,6 +1,8 @@ from dataclasses import dataclass, field from enum import Enum, auto import os +import shutil +import time import zipfile import subprocess import threading @@ -9,7 +11,7 @@ from webserver.runtimemanager import RuntimeManager from webserver.logger import get_logger, LogParser -from webserver.plugin_config_model import PluginsConfiguration, PluginConfig +from webserver.plugin_config_model import PluginsConfiguration, PluginConfig, PluginType logger, _ = get_logger("runtime", use_buffer=True) @@ -212,6 +214,9 @@ def update_plugin_configurations(generated_dir: str = "core/generated"): else: build_state.log(f"[WARNING] {message}\n") + # VPP plugins are handled separately via vpp_plugins.conf — see + # apply_vpp_plugin_conf(). Nothing to do here for VPP. + # Save the updated configuration if plugins_config.to_file(plugins_conf_path): build_state.log(f"[INFO] Plugin configuration update complete. {plugins_updated} plugins updated.\n") @@ -235,8 +240,99 @@ def update_plugin_configurations(generated_dir: str = "core/generated"): build_state.log("[ERROR] Failed to save updated plugin configuration\n") -def run_compile(runtime_manager: RuntimeManager, cwd: str = "core/generated"): - """Run compile script synchronously (wait for completion) and update status/logs.""" +def _wait_for_plc_idle(runtime_manager: RuntimeManager, timeout_s: float) -> bool: + """Poll status_plc() until the runtime is NOT in a transition. + + The runtime reports STATUS:TRANSITIONING while a stop/start worker + is in flight (plc_state has flipped but the unload/load work hasn't + completed). Any other STATUS (STOPPED, EMPTY, INIT, RUNNING) means + the runtime is settled and ready to accept the next command. + + Used before the cleanup script runs, so the .so move doesn't race + against an in-flight unload, and before sending START, so the + runtime can actually process the command instead of returning + COMMAND:BUSY. Tolerates the case where the PLC was never started + (state == INIT or EMPTY) — those return immediately since there's + no transition to wait for. + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + resp = runtime_manager.status_plc() + if resp and "TRANSITIONING" not in resp.upper(): + return True + time.sleep(0.1) + return False + + +def apply_vpp_plugin_conf(generated_dir: str = "core/generated") -> None: + """Apply or remove the VPP plugin configuration for this upload. + + VPP plugins are fully owned by the editor: it sends a + ``vpp_plugins.conf`` alongside the program when the target is a VPP + board, and omits it for vanilla builds. This function is the single + authoritative gate: + + * **Upload includes vpp_plugins.conf** → copy it to the runtime root + so the C-side plugin loader picks it up at the next PLC start. + Also copy each plugin's JSON config from ``conf/`` into the VPP + build output directory (``build/vpp/``) so the .so can read it + from the stable location listed in vpp_plugins.conf. + + * **Upload does not include vpp_plugins.conf** → delete any existing + ``vpp_plugins.conf`` from the runtime root. This ensures a + vanilla upload never inadvertently loads a VPP driver left over + from a previous project, regardless of what .so files exist in + ``build/vpp/``. + """ + VPP_CONF_DEST = "vpp_plugins.conf" + VPP_BUILD_DIR = "build/vpp" + uploaded_conf = os.path.join(generated_dir, "vpp_plugins.conf") + + if os.path.exists(uploaded_conf): + # Copy vpp_plugins.conf to runtime root + shutil.copy2(uploaded_conf, VPP_CONF_DEST) + build_state.log(f"[INFO] VPP: installed vpp_plugins.conf from upload\n") + + # Copy each VPP plugin's config file to the path declared in + # vpp_plugins.conf (the config_path field). That field is the + # single source of truth for where the .so will look for its + # config at runtime — use it directly rather than constructing + # a separate destination. + conf_dir = os.path.join(generated_dir, "conf") + vpp_conf_plugins = PluginsConfiguration.from_file(VPP_CONF_DEST) + runtime_root = os.path.abspath(".") + for p in vpp_conf_plugins.plugins: + if not p.config_path: + continue + src_config = os.path.join(conf_dir, f"{p.name}.json") + if not os.path.exists(src_config): + build_state.log(f"[WARNING] VPP: conf/{p.name}.json not found in upload, skipping\n") + continue + dest_config = os.path.normpath(p.config_path) + # Guard against path traversal in editor-generated vpp_plugins.conf + if not os.path.abspath(dest_config).startswith(runtime_root): + build_state.log(f"[WARNING] VPP: config_path '{p.config_path}' escapes runtime root, skipping\n") + continue + os.makedirs(os.path.dirname(dest_config), exist_ok=True) + shutil.copy2(src_config, dest_config) + build_state.log(f"[INFO] VPP: copied {p.name}.json to {dest_config}\n") + else: + # No VPP in this upload — remove any stale vpp_plugins.conf so + # the plugin loader does not attempt to load old VPP drivers. + if os.path.exists(VPP_CONF_DEST): + os.remove(VPP_CONF_DEST) + build_state.log("[INFO] VPP: removed stale vpp_plugins.conf (no VPP in upload)\n") + + +def run_compile(runtime_manager: RuntimeManager, cwd: str = "core/generated", clean: bool = False): + """Run compile script synchronously (wait for completion) and update status/logs. + + When ``clean=True`` (editor's "Clean build and upload" option), wipe the + Make-managed ``core/build/`` directory and clear the ccache contents + before invoking compile.sh. This forces a full recompile from scratch + even when source content hashes match the cached objects — useful when + the user suspects a stale or corrupted cache. + """ script_path: str = "./scripts/compile.sh" build_state.status = BuildStatus.COMPILING @@ -248,15 +344,51 @@ def stream_output(pipe, prefix): build_state.log(msg) pipe.close() - def wait_and_finish(proc: subprocess.Popen, step_name: str): + def wait_step(proc: subprocess.Popen, step_name: str) -> bool: + """Wait for a subprocess and log the result. Returns True on + zero-exit. Caller is responsible for combining results — does + NOT mutate build_state.status, since that's only updated once + after every step (compile + cleanup) completes, so a successful + compile isn't masked by a failed cleanup or vice-versa.""" exit_code = proc.wait() - build_state.exit_code = exit_code if exit_code == 0: - build_state.status = BuildStatus.SUCCESS build_state.log(f"[INFO] {step_name} finished successfully\n") - else: - build_state.status = BuildStatus.FAILED - build_state.log(f"[ERROR] {step_name} failed (exit={exit_code})\n") + return True + build_state.log(f"[ERROR] {step_name} failed (exit={exit_code})\n") + return False + + # --- Optional clean step --- + if clean: + build_state.log("[INFO] Clean build requested — wiping core/build/ and ccache\n") + # Wipe the per-project object cache. shutil.rmtree avoids needing + # `make clean` (which would require the Makefile to be in cwd). + build_dir = "core/build" + if os.path.exists(build_dir): + try: + shutil.rmtree(build_dir) + except OSError as e: + build_state.log(f"[WARNING] Failed to remove {build_dir}: {e}\n") + # Wipe ccache. Failures here are non-fatal — `ccache -C` returning + # non-zero (e.g. ccache not installed) shouldn't abort the build, + # since the build folder wipe alone already invalidates per-file + # caches that live in build/. + try: + ccache_proc = subprocess.run( + ["ccache", "-C"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if ccache_proc.returncode == 0: + build_state.log("[INFO] ccache cleared\n") + else: + build_state.log( + f"[WARNING] ccache -C exited {ccache_proc.returncode}: " + f"{ccache_proc.stderr.strip() or 'no error output'}\n" + ) + except FileNotFoundError: + build_state.log("[INFO] ccache not installed — skipping cache wipe\n") # --- Compile step --- compile_proc = subprocess.Popen( @@ -270,11 +402,24 @@ def wait_and_finish(proc: subprocess.Popen, step_name: str): threading.Thread(target=stream_output, args=(compile_proc.stdout, ""), daemon=True).start() threading.Thread(target=stream_output, args=(compile_proc.stderr, "[ERROR] "), daemon=True).start() - # Block until compile finishes - wait_and_finish(compile_proc, "Build") - - # Stop PLC before cleanup + # Block until compile finishes. + compile_ok = wait_step(compile_proc, "Build") + + # Stop the running PLC before swapping the .so. stop_plc() returns + # as soon as the runtime ACKs over the socket, but the actual task + # / plugin / .so teardown continues asynchronously — wait for the + # runtime to settle (not in a transition) before letting the + # cleanup script touch build/new_libplc.so. Otherwise the new .so + # could be moved into place (or the old one held open) while + # teardown is still in progress. _wait_for_plc_idle returns + # immediately for the "PLC was never started" case (state == INIT + # / EMPTY) — there's no transition to wait for. runtime_manager.stop_plc() + if not _wait_for_plc_idle(runtime_manager, timeout_s=30.0): + build_state.log( + "[WARNING] Runtime stayed in TRANSITIONING for 30s; " + "proceeding with cleanup anyway\n" + ) # --- Cleanup step --- cleanup_proc = subprocess.Popen( @@ -288,12 +433,52 @@ def wait_and_finish(proc: subprocess.Popen, step_name: str): threading.Thread(target=stream_output, args=(cleanup_proc.stdout, ""), daemon=True).start() threading.Thread(target=stream_output, args=(cleanup_proc.stderr, "[ERROR] "), daemon=True).start() - # Block until cleanup finishes - wait_and_finish(cleanup_proc, "Cleanup") + cleanup_ok = wait_step(cleanup_proc, "Cleanup") + + # Update build_state.status from the COMBINED result. Previously, + # only the cleanup result mattered (the second wait_and_finish + # overwrote whatever the compile set), so a failed compile + a + # successful cleanup would have been reported as SUCCESS, and a + # successful compile + a failed cleanup as FAILED — neither + # matches what actually happened. + if compile_ok and cleanup_ok: + build_state.status = BuildStatus.SUCCESS + build_state.exit_code = 0 + else: + build_state.status = BuildStatus.FAILED + build_state.exit_code = 1 - # Restart PLC only if everything succeeded if build_state.status == BuildStatus.SUCCESS: - runtime_manager.reset_crash_tracking() - runtime_manager.start_plc() + # Re-run plugin configuration now that compile.sh has produced any + # VPP plugin .so files. The pre-compile call at upload time can only + # register pre-built plugins; VPP plugins are compiled on-target + # during run_compile, so their entries in plugins.conf have to be + # written after the compile step succeeds. + # + # Hold status back in COMPILING while we finalize plugins.conf so + # the editor doesn't poll SUCCESS and send START before the VPP + # plugin entry is written. + # + # Wrap in try/except: if update_plugin_configurations raises (e.g., + # malformed plugins.conf, OS error rewriting it), we MUST flip + # status to FAILED. Without this, a raised exception bubbles out + # of run_compile leaving build_state.status pinned at COMPILING, + # and the editor's polling loop hangs forever waiting for a + # terminal status. + build_state.status = BuildStatus.COMPILING + try: + update_plugin_configurations(cwd) + build_state.status = BuildStatus.SUCCESS + # Reset crash tracking after a successful build — the program + # changed, so any previous crash pattern no longer applies. Do + # NOT auto-start the PLC here: the editor is responsible for + # sending START once it has confirmed a clean build, which + # gives it control over retries when the previous STOP + # transition is still finishing (COMMAND:BUSY window). + runtime_manager.reset_crash_tracking() + except Exception as e: + build_state.log(f"[ERROR] Failed to update plugin configurations: {e}\n") + build_state.status = BuildStatus.FAILED + build_state.exit_code = 1 else: build_state.log("[WARNING] PLC program has not been updated because the build failed\n") diff --git a/webserver/plugin_config_model.py b/webserver/plugin_config_model.py index 4722975c..7031272e 100644 --- a/webserver/plugin_config_model.py +++ b/webserver/plugin_config_model.py @@ -371,5 +371,50 @@ def update_plugins_from_config_dir(self, config_dir: str, copy_to_plugin_dirs: b plugin.enabled = False plugins_updated += 1 updates.append(f"Disabled plugin '{plugin.name}' (no config file found)") - - return plugins_updated, updates \ No newline at end of file + + return plugins_updated, updates + + def has_plugin(self, name: str) -> bool: + """Check if a plugin with the given name exists in the configuration.""" + return any(p.name == name for p in self.plugins) + + def add_plugin(self, name: str, path: str, enabled: bool, + plugin_type: PluginType, config_path: str = "", + venv_path: str = "") -> None: + """ + Add a new plugin entry to the configuration. + + Args: + name: Plugin identifier + path: Path to plugin binary (.so) or script (.py) + enabled: Whether the plugin should be enabled + plugin_type: PYTHON or NATIVE + config_path: Path to plugin-specific config file + venv_path: Virtual environment path (Python plugins only) + """ + plugin = PluginConfig( + name=name, + path=path, + enabled=enabled, + plugin_type=plugin_type, + config_path=config_path, + venv_path=venv_path, + ) + self.plugins.append(plugin) + + def update_plugin_path(self, name: str, path: str) -> bool: + """ + Update the binary path for an existing plugin. + + Args: + name: Plugin identifier + path: New path to plugin binary + + Returns: + True if the plugin was found and updated, False otherwise + """ + for plugin in self.plugins: + if plugin.name == name: + plugin.path = path + return True + return False \ No newline at end of file diff --git a/webserver/unixclient.py b/webserver/unixclient.py index 8ef3135a..840a7bfb 100644 --- a/webserver/unixclient.py +++ b/webserver/unixclient.py @@ -21,17 +21,31 @@ def is_connected(self): def connect(self): """Connect to the Unix socket server""" + # Release any prior socket before claiming a new fd. Without this, + # repeated reconnect attempts (e.g. while the runtime is down) + # leak a socket fd per call and eventually hit EMFILE. + self.close() + if not os.path.exists(self.socket_path): raise FileNotFoundError(f"Socket not found: {self.socket_path}") + sock = None try: logger.debug("Connecting to socket %s", self.socket_path) - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.settimeout(1.0) # 1s timeout on blocking calls - self.sock.connect(self.socket_path) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(1.0) # 1s timeout on blocking calls + sock.connect(self.socket_path) + # Only publish the socket after a successful connect, so + # is_connected() never returns True for an unconnected fd. + self.sock = sock logger.debug("Connected to server socket %s", self.socket_path) except Exception as e: logger.error("Failed to connect: %s", e) + if sock is not None: + try: + sock.close() + except OSError: + pass def send_message(self, msg: str): if not self.sock: