Skip to content
90 changes: 89 additions & 1 deletion core/src/drivers/plugins/python/modbus_slave/simple_modbus.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,93 @@
)


class OpenPLCDeviceContext(ModbusDeviceContext):
"""
Custom Modbus device context that correctly handles FC5/FC6 response echo.

Per Modbus specification, FC5 (Write Single Coil) and FC6 (Write Single Register)
responses should echo the requested value, not the current buffer state.

pymodbus implements FC5/FC6 by calling setValues() then getValues() and using
the read-back value in the response. However, OpenPLC uses a journal-based write
system where writes are queued and applied at the next PLC scan cycle. This means
getValues() may return the old value before the journal write is applied.

This class caches values written via FC5/FC6 and returns them on the subsequent
getValues() call, ensuring the response correctly echoes the requested value.

The cache uses asyncio task ID to isolate concurrent requests, preventing race
conditions when multiple FC5/FC6 requests arrive simultaneously.
"""

# Function codes that require echo response (write single operations)
_ECHO_FUNCTION_CODES = (5, 6) # FC5: Write Single Coil, FC6: Write Single Register

def __init__(self, *args, **kwargs):
"""Initialize with an empty pending writes cache."""
super().__init__(*args, **kwargs)
# Cache for FC5/FC6 echo values: (task_id, func_code, address) -> value
self._pending_writes = {}

def setValues(self, func_code, address, values):
"""
Set values in the datastore and cache for FC5/FC6 echo response.

For FC5/FC6, the written values are cached with the current asyncio task ID
so that the subsequent getValues() call can return the correct echo value.
"""
# Cache values for FC5/FC6 echo response
if func_code in self._ECHO_FUNCTION_CODES:
try:
task_id = id(asyncio.current_task())
for i, val in enumerate(values):
cache_key = (task_id, func_code, address + i)
self._pending_writes[cache_key] = val
except RuntimeError:
# No running event loop - skip caching (shouldn't happen in normal operation)
pass

return super().setValues(func_code, address, values)

def getValues(self, func_code, address, count=1):
"""
Get values from the datastore, using cached values for FC5/FC6 echo response.

For FC5/FC6, if cached values exist for this task, they are returned and
removed from the cache. This ensures the response echoes the requested value
per Modbus specification.
"""
# For FC5/FC6, return cached echo values if available
if func_code in self._ECHO_FUNCTION_CODES:
try:
task_id = id(asyncio.current_task())
results = []
all_cached = True

for i in range(count):
cache_key = (task_id, func_code, address + i)
if cache_key in self._pending_writes:
results.append(self._pending_writes.pop(cache_key))
else:
all_cached = False
break

if all_cached:
return results

# If not all values were cached, clean up any partial cache entries
# and fall through to normal read
for i in range(len(results), count):
cache_key = (task_id, func_code, address + i)
self._pending_writes.pop(cache_key, None)

except RuntimeError:
# No running event loop - fall through to normal read
pass

return super().getValues(func_code, address, count)


class OpenPLCCoilsDataBlock(ModbusSparseDataBlock):
"""Custom Modbus coils data block that mirrors OpenPLC bool_output using SafeBufferAccess"""

Expand Down Expand Up @@ -996,7 +1083,8 @@ def init(args_capsule):
)

# Create device context with all OpenPLC-connected data blocks
device = ModbusDeviceContext(
# Uses OpenPLCDeviceContext for correct FC5/FC6 response echo handling
device = OpenPLCDeviceContext(
di=discrete_inputs_block, # Discrete Inputs -> bool_input (%IX)
co=coils_block, # Coils -> bool_output (%QX) + bool_memory (%MX)
ir=input_registers_block, # Input Registers -> int_input (%IW)
Expand Down
15 changes: 15 additions & 0 deletions core/src/plc_app/include/iec_python.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

#include <stddef.h>
#include <sys/types.h>
#include <unistd.h>

#ifdef __cplusplus
extern "C"
Expand Down Expand Up @@ -80,6 +81,20 @@ extern "C"
void python_loader_set_loggers(void (*log_info_func)(const char *, ...),
void (*log_error_func)(const char *, ...));

/**
* @brief Cleanup all Python function blocks
*
* This function must be called before unloading libplc.so to:
* 1. Terminate all Python subprocesses (SIGTERM, then SIGKILL)
* 2. Wait for all runner threads to exit
* 3. Unmap and unlink shared memory regions
* 4. Remove Python script files
*
* Failure to call this before dlclose() will cause a crash because
* the runner threads' code lives in the shared library.
*/
void python_blocks_cleanup(void);

#ifdef __cplusplus
}
#endif
Expand Down
12 changes: 12 additions & 0 deletions core/src/plc_app/plc_state_manager.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#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"
Expand Down Expand Up @@ -206,6 +207,17 @@ int unload_plc_program(PluginManager *pm)
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;
Expand Down
Loading