Skip to content

Commit 3fed1fe

Browse files
authored
Merge pull request #141 from Autonomy-Logic/development
Release v4.1.5
2 parents 885a7dd + 9384ada commit 3fed1fe

6 files changed

Lines changed: 476 additions & 182 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v4.1.4
1+
v4.1.5

core/src/drivers/plugins/python/modbus_master/modbus_master_plugin.py

Lines changed: 141 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -433,42 +433,64 @@ def stop(self):
433433
self._stop_event.set()
434434

435435

436-
class ModbusRtuBusHandler(threading.Thread):
436+
class ModbusBusHandler(threading.Thread):
437437
"""
438-
Handles multiple Modbus RTU devices on a single serial port (multi-drop).
439-
One thread per serial bus, managing multiple slave IDs.
440-
441-
This differs from ModbusSlaveDevice in that:
442-
- A single serial connection is shared by multiple devices
443-
- Each device has a unique slave_id
444-
- IO operations include the slave_id to route to the correct device
438+
Handles multiple Modbus devices that share ONE connection, multiplexed by
439+
slave/unit ID. One thread per bus, managing multiple slave IDs over a single
440+
connection. Used for two cases:
441+
- RTU multi-drop: several devices on one serial port (different slave IDs).
442+
- TCP gateway (TCP-to-RTU converter): several serial slaves behind one
443+
IP:port, distinguished by their unit/slave ID.
444+
445+
This differs from ModbusSlaveDevice (one connection per device) in that:
446+
- A single connection is shared by multiple devices.
447+
- Each device has a unique slave_id.
448+
- Each IO operation carries that slave_id (device_id / MBAP unit byte) so the
449+
gateway or bus routes it to the correct slave.
450+
451+
The run() loop is transport-agnostic; only the connection setup differs.
445452
"""
446453

447454
def __init__(
448455
self,
449-
serial_config: dict, # {serial_port, baud_rate, parity, stop_bits, data_bits, timeout_ms}
450-
devices: List[Any], # List of ModbusDeviceConfig on this bus
456+
transport: str, # "tcp" or "rtu"
457+
connection_config: dict, # tcp: {host, port, timeout_ms};
458+
# rtu: {serial_port, baud_rate, parity, stop_bits, data_bits, timeout_ms}
459+
devices: List[Any], # List of ModbusDeviceConfig sharing this connection
451460
sba: SafeBufferAccess,
452461
plugin_logger: PluginLogger,
453462
):
454463
super().__init__(daemon=True)
455-
self.serial_config = serial_config
464+
self.transport = transport
465+
self.connection_config = connection_config
456466
self.devices = devices
457467
self.sba = sba
458468
self.logger = plugin_logger
459469
self._stop_event = threading.Event()
460470

461-
# Create single connection for the entire bus
462-
self.connection_manager = ModbusConnectionManager(
463-
transport="rtu",
464-
serial_port=serial_config["serial_port"],
465-
baud_rate=serial_config["baud_rate"],
466-
parity=serial_config["parity"],
467-
stop_bits=serial_config["stop_bits"],
468-
data_bits=serial_config["data_bits"],
469-
timeout_ms=serial_config["timeout_ms"],
470-
slave_id=1, # Default, will use device-specific slave_id per operation
471-
)
471+
# Single shared connection for the whole bus/gateway. slave_id here is a
472+
# placeholder; every request uses its device-specific slave_id below.
473+
if transport == "tcp":
474+
self.connection_manager = ModbusConnectionManager(
475+
transport="tcp",
476+
host=connection_config["host"],
477+
port=connection_config["port"],
478+
timeout_ms=connection_config["timeout_ms"],
479+
slave_id=1,
480+
)
481+
self.name = f"ModbusTcpGateway-{connection_config['host']}:{connection_config['port']}"
482+
else:
483+
self.connection_manager = ModbusConnectionManager(
484+
transport="rtu",
485+
serial_port=connection_config["serial_port"],
486+
baud_rate=connection_config["baud_rate"],
487+
parity=connection_config["parity"],
488+
stop_bits=connection_config["stop_bits"],
489+
data_bits=connection_config["data_bits"],
490+
timeout_ms=connection_config["timeout_ms"],
491+
slave_id=1, # Default, will use device-specific slave_id per operation
492+
)
493+
self.name = f"ModbusRtuBus-{connection_config['serial_port']}"
472494

473495
# Build consolidated IO point list with slave_id
474496
# Each entry: {"point": io_point, "slave_id": device.slave_id, "device_name": device.name}
@@ -488,9 +510,8 @@ def __init__(
488510
) if all_cycle_times else 1000
489511

490512
device_names = ", ".join([d.name for d in devices])
491-
self.name = f"ModbusRtuBus-{serial_config['serial_port']}"
492513
self.logger.info(
493-
f"[{self.name}] RTU bus handler created for devices: {device_names} "
514+
f"[{self.name}] bus handler created for devices: {device_names} "
494515
f"({len(self.all_io_points)} total IO points, GCD cycle: {self.gcd_cycle_time_ms}ms)"
495516
)
496517

@@ -812,6 +833,10 @@ def stop(self):
812833
self._stop_event.set()
813834

814835

836+
# Backward-compatible alias: the bus handler used to be RTU-only.
837+
ModbusRtuBusHandler = ModbusBusHandler
838+
839+
815840
def group_rtu_devices_by_bus(devices: List[Any]) -> dict:
816841
"""
817842
Group RTU devices by serial port configuration (forming unique "buses").
@@ -861,6 +886,52 @@ def group_rtu_devices_by_bus(devices: List[Any]) -> dict:
861886
return buses
862887

863888

889+
def group_tcp_devices_by_endpoint(devices: List[Any]) -> dict:
890+
"""
891+
Group TCP devices by (host, port) endpoint.
892+
893+
Several devices may share one endpoint when it is a Modbus gateway
894+
(TCP-to-RTU converter): one IP:port fronts multiple serial slaves, each with
895+
a distinct slave/unit ID. Devices on the same endpoint share a single TCP
896+
connection (one ModbusBusHandler thread) and are routed by slave_id; a lone
897+
device on an endpoint keeps the simpler one-thread ModbusSlaveDevice path.
898+
899+
Args:
900+
devices: List of ModbusDeviceConfig with transport="tcp"
901+
902+
Returns:
903+
Dictionary keyed by "host:port":
904+
{
905+
"host:port": {
906+
"config": {host, port, timeout_ms},
907+
"devices": [list of devices on this endpoint],
908+
}
909+
}
910+
"""
911+
endpoints = {}
912+
913+
for device in devices:
914+
endpoint_key = f"{device.host}:{device.port}"
915+
916+
if endpoint_key not in endpoints:
917+
endpoints[endpoint_key] = {
918+
"config": {
919+
"host": device.host,
920+
"port": device.port,
921+
"timeout_ms": device.timeout_ms,
922+
},
923+
"devices": [],
924+
}
925+
926+
endpoints[endpoint_key]["devices"].append(device)
927+
928+
# Use minimum timeout across all devices sharing the endpoint
929+
if device.timeout_ms < endpoints[endpoint_key]["config"]["timeout_ms"]:
930+
endpoints[endpoint_key]["config"]["timeout_ms"] = device.timeout_ms
931+
932+
return endpoints
933+
934+
864935
def init(args_capsule):
865936
"""
866937
Initialize the Modbus Master plugin.
@@ -896,8 +967,11 @@ def start_loop():
896967
Start the main loop for all configured Modbus devices.
897968
This function is called after successful initialization.
898969
899-
TCP devices: One thread per device (ModbusSlaveDevice)
900-
RTU devices: One thread per serial bus (ModbusRtuBusHandler), supporting multi-drop
970+
TCP devices: grouped by (host, port). A lone device on an endpoint runs as
971+
ModbusSlaveDevice (one connection); multiple devices on one endpoint (a
972+
Modbus gateway / TCP-to-RTU converter) share one connection via
973+
ModbusBusHandler, multiplexed by slave/unit ID.
974+
RTU devices: One thread per serial bus (ModbusBusHandler), supporting multi-drop.
901975
"""
902976
global slave_threads, modbus_master_config, safe_buffer_accessor, logger
903977

@@ -937,18 +1011,44 @@ def start_loop():
9371011

9381012
logger.info(f"Found {len(tcp_devices)} TCP device(s) and {len(rtu_devices)} RTU device(s)")
9391013

940-
# Start TCP devices (one thread per device - existing behavior)
941-
for device_config in tcp_devices:
942-
try:
943-
device_thread = ModbusSlaveDevice(device_config, safe_buffer_accessor, logger)
944-
device_thread.start()
945-
slave_threads.append(device_thread)
946-
logger.info(
947-
f"Started TCP thread for device: {device_config.name} "
948-
f"({device_config.host}:{device_config.port}, slave_id={device_config.slave_id})"
949-
)
950-
except Exception as e:
951-
logger.error(f"Failed to start TCP thread for device {device_config.name}: {e}")
1014+
# Group TCP devices by (host, port). A lone device on an endpoint keeps
1015+
# the simple one-thread-per-device path; multiple devices on one endpoint
1016+
# (a Modbus gateway / TCP-to-RTU converter) share a single TCP connection
1017+
# and are multiplexed by slave/unit ID via ModbusBusHandler.
1018+
if tcp_devices:
1019+
tcp_endpoints = group_tcp_devices_by_endpoint(tcp_devices)
1020+
logger.info(f"TCP devices grouped into {len(tcp_endpoints)} endpoint(s)")
1021+
1022+
for endpoint_key, endpoint_info in tcp_endpoints.items():
1023+
endpoint_devices = endpoint_info["devices"]
1024+
try:
1025+
if len(endpoint_devices) == 1:
1026+
device_config = endpoint_devices[0]
1027+
device_thread = ModbusSlaveDevice(device_config, safe_buffer_accessor, logger)
1028+
device_thread.start()
1029+
slave_threads.append(device_thread)
1030+
logger.info(
1031+
f"Started TCP thread for device: {device_config.name} "
1032+
f"({device_config.host}:{device_config.port}, slave_id={device_config.slave_id})"
1033+
)
1034+
else:
1035+
gateway_thread = ModbusBusHandler(
1036+
transport="tcp",
1037+
connection_config=endpoint_info["config"],
1038+
devices=endpoint_devices,
1039+
sba=safe_buffer_accessor,
1040+
plugin_logger=logger,
1041+
)
1042+
gateway_thread.start()
1043+
slave_threads.append(gateway_thread)
1044+
device_names = ", ".join([d.name for d in endpoint_devices])
1045+
slave_ids = ", ".join([str(d.slave_id) for d in endpoint_devices])
1046+
logger.info(
1047+
f"Started TCP gateway thread: {endpoint_key} "
1048+
f"(devices: {device_names}, slave_ids: {slave_ids})"
1049+
)
1050+
except Exception as e:
1051+
logger.error(f"Failed to start TCP thread(s) for endpoint {endpoint_key}: {e}")
9521052

9531053
# Group RTU devices by serial port configuration
9541054
if rtu_devices:
@@ -958,8 +1058,9 @@ def start_loop():
9581058
# Start RTU bus handlers (one thread per serial port)
9591059
for bus_key, bus_info in rtu_buses.items():
9601060
try:
961-
bus_thread = ModbusRtuBusHandler(
962-
serial_config=bus_info["config"],
1061+
bus_thread = ModbusBusHandler(
1062+
transport="rtu",
1063+
connection_config=bus_info["config"],
9631064
devices=bus_info["devices"],
9641065
sba=safe_buffer_accessor,
9651066
plugin_logger=logger,

core/src/drivers/plugins/python/shared/plugin_config_decode/modbus_master_config_model.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,20 @@ def validate(self) -> None:
236236
tcp_devices = [d for d in self.devices if d.transport == "tcp"]
237237
rtu_devices = [d for d in self.devices if d.transport == "rtu"]
238238

239-
# Check for duplicate host:port combinations for TCP devices
240-
host_port_combinations = [(device.host, device.port) for device in tcp_devices]
241-
if len(host_port_combinations) != len(set(host_port_combinations)):
242-
raise ValueError("Duplicate host:port combinations found for TCP devices. Each TCP device must have a unique host:port combination.")
239+
# Multiple TCP devices may share the same host:port — that is exactly how
240+
# an Ethernet-to-Modbus gateway (TCP-to-RTU converter) is addressed: one IP,
241+
# several serial slaves distinguished by their unit/slave ID. So we key TCP
242+
# devices by (host, port, slave_id) and reject only TRUE duplicates (same
243+
# endpoint AND slave ID), which would be ambiguous. The runtime routes each
244+
# device's slave_id into the MBAP unit-ID byte and shares one TCP connection
245+
# per host:port (see group_tcp_devices_by_endpoint / ModbusBusHandler).
246+
tcp_endpoints = [(device.host, device.port, device.slave_id) for device in tcp_devices]
247+
if len(tcp_endpoints) != len(set(tcp_endpoints)):
248+
raise ValueError(
249+
"Duplicate (host, port, slave_id) found for TCP devices. Multiple "
250+
"devices may share a host:port (a Modbus gateway) only if their "
251+
"slave IDs differ."
252+
)
243253

244254
# Check for duplicate slave IDs on the same serial bus for RTU devices
245255
# Group RTU devices by serial bus (serial_port + baud_rate + parity + stop_bits + data_bits)

scripts/compile.sh

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,50 @@ check_required_files
5555

5656
# Build the program — actual rules live in scripts/Makefile.strucpp.
5757
#
58-
# Leave one core free for the rest of the system. On a 4-core Pi 4
59-
# (the smallest target we ship to), `-j$(nproc)` saturates every core
60-
# with g++ and starves the webserver / runtime monitor of CPU during
61-
# the compile; combined with the Pi's slow SD-card swap, that's been
62-
# observed to make port-8443 RST new connections for 60+ seconds while
63-
# the compile thrashes. `nproc - 1` keeps one core reserved for the
64-
# Flask webserver, the runtime monitor thread, and any plugins still
65-
# running — only ~25 % slower per build on a Pi 4, but the device stays
66-
# responsive throughout. Floor at 1 so single-core targets don't end up
67-
# with `-j0` (which means "unlimited" in GNU make, i.e. fork-bomb).
68-
JOBS=$(nproc)
69-
[ "$JOBS" -gt 1 ] && JOBS=$((JOBS - 1))
58+
# Pick the build parallelism (`-j`) as min(CPU cap, memory cap). Both
59+
# are real constraints on the Pi-class targets we ship to, and either
60+
# bound alone has caused field outages.
61+
#
62+
# CPU cap = nproc - 1. On a 4-core Pi 4 `-j$(nproc)` saturates every
63+
# core with g++ and starves the webserver / runtime monitor of CPU
64+
# during the compile; combined with the Pi's slow SD-card swap, that
65+
# made port-8443 RST new connections for 60+ seconds while the compile
66+
# thrashed. Reserving one core for the Flask webserver, the runtime
67+
# monitor thread, and any plugins keeps the device responsive
68+
# throughout — only ~25 % slower per build on a Pi 4.
69+
#
70+
# Memory cap = total RAM (rounded to nearest GB) — one parallel job
71+
# per gigabyte. Each cc1plus invocation on OpenPLC-generated TUs
72+
# peaks at ~500–700 MB on a Pi 4. With `-j3` on a 2 GB device,
73+
# three concurrent cc1pluses exhaust RAM + swap and the system enters
74+
# a swap-thrash deadlock where no compile process makes progress —
75+
# requires a physical reboot to recover on a headless target.
76+
# Capping at one job per GB gives ~500 MB per cc1plus + headroom for
77+
# the kernel, the webserver, the PLC core, and the plugins. The
78+
# `+512` before dividing rounds MemTotal to the nearest GB so a 2 GB
79+
# Pi (which reports ~1.8 GiB usable after kernel reserves) doesn't
80+
# get wrongly demoted to -j1.
81+
#
82+
# Floor at 1 so single-core or sub-GB targets don't end up with `-j0`
83+
# (which means "unlimited" in GNU make, i.e. fork-bomb).
84+
#
85+
# Worked examples:
86+
# Pi 4 2 GB (nproc=4): cpu=3, mem=2 → -j2
87+
# Pi 4 4 GB (nproc=4): cpu=3, mem=4 → -j3
88+
# Pi 4 8 GB (nproc=4): cpu=3, mem=8 → -j3
89+
# 1 GB / 1 core VM: cpu=1, mem=1 → -j1
90+
# Workstation 8c/16GB: cpu=7, mem=16 → -j7
91+
CPU_JOBS=$(nproc)
92+
[ "$CPU_JOBS" -gt 1 ] && CPU_JOBS=$((CPU_JOBS - 1))
93+
MEM_KB=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
94+
MEM_MB=$((MEM_KB / 1024))
95+
MEM_JOBS=$(( (MEM_MB + 512) / 1024 ))
96+
[ "$MEM_JOBS" -lt 1 ] && MEM_JOBS=1
97+
if [ "$CPU_JOBS" -lt "$MEM_JOBS" ]; then
98+
JOBS=$CPU_JOBS
99+
else
100+
JOBS=$MEM_JOBS
101+
fi
70102
make -j"$JOBS" -f scripts/Makefile.strucpp
71103

72104
# -----------------------------------------------------------------------

0 commit comments

Comments
 (0)