@@ -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+
815840def 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+
864935def 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 ,
0 commit comments