Repository: pymodbus-dev/pymodbus

Issue:
Cannot participate as slave on a multi-drop bus if there are other slaves
### Versions

* Python: 3.7
* OS: Current Raspberry OS
* Pymodbus: 2.5.2
* Modbus Hardware (if used): Exar CC-USB-RS485-150U / Exar 21B1411

### Pymodbus Specific
* Server: rtu - sync + async
* Client: rtu - sync + async

### Description

In serial/RTU mode, PyModbus participates on a Modbus bus using PySerial. Unfortunately, PySerial's `read` behaves differently to `recv` on sockets, as the pySerial timeout defines how long time it has to fill the 1024 bytes buffer of PyModbus before it has to return whatever it managed to get instead of filling the buffer with the payload of a TCP packet. This results in delayed processing as it could take up to a second for the `ModbusSerialServer` to start processing the message(s). The delay causes the requestor to retry its request, further filling the buffer of identical requests.

PyModbus in general seem to be unable to handle participation on multi-drop bus as Modbus over RS485 is, because a server can only parse _requests_. It gets confused seeing Modbus _responses_ it has not generated itself on the wire it serves on as a server.

It seems a server can only, and is only expected to, have a `ServerDecoder`. Such a decoder can only parse Modbus Requests. When it fails, such as when a Response appears on the line, it will flush the buffer and lose data, including valid Requests still pending to be parsed. This flush of the input buffer again causes the requestor to retry its request and may/will trigger a failure-to-respond status of the PyModbus-implemented server.

While PyModbus attempts to do add framing of outgoing data by pausing between frames for 3.5 char times, it does not attempt to detect pauses between frames on incoming data. This makes the serial stream be an unframed series of bytes. As consequence, to recover framing information it must properly be able to parse both requests and responses (of all types present on the bus) to not come out of sync. This it does not do (yet?), either.



Discussion:
Can you please check with version 3.0.2, I believe this is no longer an issue.
I've looked into it and I don't think it's possible to solve this at the python level.

* 1.5 char modbus timeout is about 1.5ms at 9600bps

pyserial's inter_byte_timeout only has a granularity of 0.1s, so it won't work.

The usual USB-RS485 or USB-serial adapters only run "full speed" USB, ie 12 Mbps and they transmit one frame per millisecond, so even if the OS told userland code at what microsecond timestamp a serial byte arrived on USB, the OS itself it would not know the exact arrival time of the bytes to the USB dongle with better than millisecond precision.

With hardware UARTs like the ones embedded in the Pi's, then the OS has at least a chance to implement such a short timeout, but will it? That's another question. I would bet on "nope" because heavyweight OSes like linux don't guarantee such low latencies anyway. Besides these chips' UARTs usually have FIFOs and DMA to avoid triggering too many interrupts, and by the time the bytes are in the FIFO, arrival time information is lost. So the UART hardware itself would have to have an inter_byte timeout feature. That's a thing in some microcontrollers, precisely to implement modbus.

Besides from the python side there can be latency between byte arrival time and serial.read() return due to the computer multitasking, other async coroutines running, etc. So measuring arrival time from the python side should only result in more dropped packets when latency is higher.

The simplest solution looks to be a hardware fix, with a microcontroller acting as a serial-to-modbus translator. On a micro running no other task there will be no latency issues to implement an inter-byte timeout. The micro would have to frame data transmitted to pymodbus to indicate packets, fortunately modbus is pretty slow and USB-serial links can run at hundreds of kbps so framing overhead shouldn't be an issue.

https://stackoverflow.com/questions/58898980/how-to-set-inter-byte-delay-timeout-to-milliseconds
https://github.com/pyserial/pyserial/issues/88


The problem here is not timing, but that he server receives responses from other slaves. When receiving a message it must be validated that it is destined for the server and otherwise discharged.
That's easy: if the modbus frame's address header doesn't match the server's address, then the whole frame can be dropped. Problem is, in order to do that, the end of the frame has to be found, and the only way to do that is to detect inter-byte delay which is not possible for reasons explained above.

Without the extra meaning from the inter-byte delay, python will have no idea where the frames begin or end, unless we're the only master, or we're the only slave... 

Parsing the packets wouldn't help: if another device crashes and stops its transmission mid-frame, the bus becomes available immediately after the timeout, so the master can transmit immediately if it wants, for example it could retry the transaction. And pyserial would receive one truncated packet followed by the new transmission, with no way to distinguish the two.
There are quite a number of options, which will work….we do not need the inter har,

-  we can store the request and from that calculate the response.
-  the slave id is at the beginning of the response so we do need the whole frame


Hey is there any update or workaroud to this?

I have just encountered and learned about this issue on 
```
[[package]]
name = "pymodbus"
version = "2.5.3"
```

In the meantime I am in the process of updating to 
```
[[package]]
name = "pymodbus"
version = "3.1.3"
```

in the hopes that it has been addressed; however seeing as this issue is still opened and marked for `Version 3.3` I'm not optimistic.

I'm opened to any off-the-shelf hardware workarounds if anyone knows of such a thing? Something I can stick in front of my serial port to properly frame and filter responses from other server/slaves would be acceptable. 
it’s still open.
I know what the real problem is, the server not only receives the requests, but also the responses from other servers which is need to discard. Furthermore it needs to discard requests where the slave id do not match (this is a bit trickier to do, because we do not configure a slave id for the server).

I suspect the server breaks down, can you please provide a debug log
```
# please use the following to format logs when posting them here
import pymodbus

pymodbus.pymodbus_apply_logging_config()
```
if you use the latest dev.
@janiversen Sounds good. I have a branch ready to simply try the latest version of the library; however, I have held off on even trying that yet as I will need to be able to thoroughly explain the issue currently observed on `version = "2.5.3"`.

I have enabled pymodbus' logging at the `DEBUG` level and am currently designing a clean/simplified test set up. I know logs from `2.5.3` may not be the most useful, but I will still provide details of my test setup and those logs here in case they yield value. I will then update to `version = "3.1.3"`, repeat the experiment, and provide logs if the issue is still present.
logs from v2.5.3 does not help us, with the issue. Logs should come from the newest version (3.3.0) or dev, reason being that large parts of the code have changed.
@janiversen Here is the test setup:

We have 2x of our server/slave devices multi-dropped off of the same RS-485 physical bus. Their server/slave addresses are

- `0x7c`
- `0x9b`

All devices on the bus (both server/slaves, and the client/master) are using USB-to-RS-458 donglers which already suppress local echo for us.

Both of these server/slave devices are running

```
[[package]]
name = "pymodbus"
version = "3.2.0"
description = "A fully featured modbus protocol stack in python"
category = "main"
optional = false
python-versions = ">=3.8.0"
files = [
    {file = "pymodbus-3.2.0-py2.py3-none-any.whl", hash = "sha256:ca954cf0c7e88e7cfc0d273960919b68090e72e2a88f39d2a3b50f4ab8eaf2a9"},
    {file = "pymodbus-3.2.0.tar.gz", hash = "sha256:5769ac9a00ffc154e1761c69299c1968dcab1c2a61741b1caff89c4aea81dbcf"},
]
```

with pymodbus's logging enabled; however, the logging format being used is our own and not pymodbus's - so there will be slight differences in what you expect to see. If critical I'm happy to impose your logging format on our system temporarily and repeat the experiment.

**The problem is still present on this version of the lib.**

Our modbus client/master (modscan) is configured to alternate requesting the value of a single holding register from each server/slave. For this test, it was configured to consider a request to have timed out after `400ms`. It should wait `1500ms` between requests; though it's currently unclear to me if it respects that fully. I will be setting up an oscope to confirm. Alternately I can hack together a client/master script to be damn sure of timings.

Either way, the following is the output of the logs from server/slave `0x7c` which shows a mix of both correct behavior and incorrect. I've manually annotated after the fact for my own understanding:

```
### We receive a properly framed command for us (0x7c) and respond correctly ###
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x7c 0x3 0x0 0x0 0x0 0x1 0x8e 0x27
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Getting Frame - 0x3 0x0 0x0 0x0 0x1
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Factory Request[ReadHoldingRegistersRequest': 3]
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame advanced, resetting header!!
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] validate: fc-[3] address-1: count-1
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] getValues: fc-[3] address-1: count-1
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] send: [ReadHoldingRegistersResponse (1)]- b'7c03020000d44e'
[2023-03-09 19:29:49 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b"|\x03\x00\x00\x00\x01\x8e'"] not ready


### We receive a properly framed command for them (0x9b) and discard correctly ###
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x0 0x0 0x0 0x1 0x99 0xf0
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Not a valid unit id - 155, ignoring!!
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Resetting frame - Current Frame in buffer - 0x9b 0x3 0x0 0x0 0x0 0x1 0x99 0xf0


### We receive a properly framed response from them (0x9b) and do what? I'm not sure, but seems there is an opportunity to discard, here ###
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x2 0x0 0x0 0xe0 0x58
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x02\x00\x00\xe0X'] not ready


### We receive a properly framed command for us (0x7c) and do what? I'm not totally sure but it appears that we concatenate it with the previous response from them (0x9b). We however, do not respond and timeout instead. ###
[2023-03-09 19:29:52 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x7c 0x3 0x0 0x0 0x0 0x1 0x8e 0x27
[2023-03-09 19:29:52 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame check failed, ignoring!!
[2023-03-09 19:29:52 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Resetting frame - Current Frame in buffer - 0x9b 0x3 0x2 0x0 0x0 0xe0 0x58 0x7c 0x3 0x0 0x0 0x0 0x1 0x8e 0x27


### We receive a partial command for them (0x9b) - lacking CRC bytes. ###
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x0 0x0 0x0 0x1
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x00\x00\x00\x01'] not ready


### We later receive the CRC bytes for the previous command for them (0x9b), concatenate them (I'm assuming), and discard correctly. ###
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x99 0xf0
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Not a valid unit id - 155, ignoring!!
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Resetting frame - Current Frame in buffer - 0x9b 0x3 0x0 0x0 0x0 0x1 0x99 0xf0


### We receive a properly framed response from them (0x9b) and again, do what I'm not sure. ###
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x2 0x0 0x0 0xe0 0x58
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x02\x00\x00\xe0X'] not ready

### Again we receive a properly framed command for us but fail to respond with the same syptoms as above. ###
[2023-03-09 19:29:55 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x7c 0x3 0x0 0x0 0x0 0x1 0x8e 0x27
[2023-03-09 19:29:55 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame check failed, ignoring!!
[2023-03-09 19:29:56 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Resetting frame - Current Frame in buffer - 0x9b 0x3 0x2 0x0 0x0 0xe0 0x58 0x7c 0x3 0x0 0x0 0x0 0x1 0x8e 0x27

### Etc... ###
[2023-03-09 19:29:57 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x0 0x0 0x0 0x1 0x99
[2023-03-09 19:29:57 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x00\x00\x00\x01\x99'] not ready
[2023-03-09 19:29:57 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0xf0
```

I'm 100% happy to attempt some monkey patching if you can suggest obvious places in code to patch (in fact I'll probably start digging through your guys' source to do that, anyways).
I actually wrote that the problem is not solved in the current lib. I am very surprised to see that the server do not break down, that does not make sense.

Your analysis do help to pinpoint what we have to do, but I am back in square one because the server do not break.
Correct, the server does appear to continue operation, and does appear to eventually "re-synchronize", later responding to messages.

So I would say the timeouts appear intermittent. Anecdotally, playing with timeout, and interval parameters on the client/master does change the frequency with with the server/slaves exhibit this problem.

As I said, I will probably start looking into your guys' source tonight or tomorrow to see if I can put together some solutions or at least further my understanding of the problem.

Any suggestions of where to start looking would help me out as I'm currently totally unfamiliar with your guys' source code ;p
Looking at this line specifically:

```
### We receive a properly framed response from them (0x9b) and do what? I'm not sure, but seems there is an opportunity to discard, here ###
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x2 0x0 0x0 0xe0 0x58
[2023-03-09 19:29:51 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x02\x00\x00\xe0X'] not ready <--------------------THIS LINE
```
suggests to me that you are not currently detecting that this is actually a complete/ready frame (just from another device) and choose to hold onto it in some sort of buffer upon which later data is concatenated. To me I'm optimistic, that there is a quick fix here.

I say this because later in these same logs we see this:

```
### We receive a partial command for them (0x9b) - lacking CRC bytes. ###
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x9b 0x3 0x0 0x0 0x0 0x1
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Frame - [b'\x9b\x03\x00\x00\x00\x01'] not ready   <-------------NOTE THIS MESSAGE LACKS CRC BYTES AND IS IN FACT INCOMPLETE


### We later receive the CRC bytes for the previous command for them (0x9b), concatenate them (I'm assuming), and discard correctly. ###
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Handling data: 0x99 0xf0  <----------- WE LATER GET THE BYTES AND CONCATENATE THEM WITH THE BYTES ABOVE (I THINK) AND PROPERLY DETECT THE COMMAND TO THE OTHER SLAVE
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Not a valid unit id - 155, ignoring!!
[2023-03-09 19:29:54 +0000] [DEBUG] [140253118510912] [pymodbus.logging] Resetting frame - Current Frame in buffer - 0x9b 0x3 0x0 0x0 0x0 0x1 0x99 0xf0
```
Looking forward to see a pull request.
I have a patchover for this issue that works for our specific use cases and allows my slave devices to participate performantly on an industrial multi-drop serial RTU bus (but not completely without an occasional timeout).

I will share it here in case others are in immediate need; however, I suspect this is *not* the right way to solve this issue, so use at your own risk. YMMV.

This solution assumes the device you are operating on is fast enough to never fall behind. I.e. it can flush the entire buffer containing responses from other devices well before a command intended for it comes along. Per some of the discussion suggested by @peufeu2 I don't know that we'll be able to fully solve this issue in python. Depending on what else your software is doing in other co-routines, and also how busy your USB bus is (assuming you're using a USB-RS485 conversion) there is no guarantee your software wont ever flush a message intended for it along with commands/responses for/from other devices.

Practically speaking, I see less than 1% timeouts from my device with the following code on a very busy bus. However, I have the ability to control the polling interval (time between commands form the master to any device) and increase it to make it more favorable for my device allowing it more time to flush out garbage to/from other devices. If you don't have control of your master and it's running in extremely low latency you will have much more timeouts.

### Here's the patched over ModbusRTUFramer
```python
import logging

from pymodbus.framer.rtu_framer import ModbusRtuFramer

log = logging.getLogger(__name__)


class PatchedModbusRtuFramer(ModbusRtuFramer):
    def isFrameIntendedForUs(self, units: list[int]):
        try:
            unit = self._buffer[0]
            return unit in units
        except IndexError as e:
            return True

    def processIncomingPacket(self, data, callback, unit, **kwargs):
        if not isinstance(unit, (list, tuple)):
            unit = [unit]
        self.addToFrame(data)
        single = kwargs.get("single", False)
        while True:
            if not self.isFrameIntendedForUs(unit):
                self.resetFrame()
                log.info(f"❌ Frame - [{data}] not intended for us, ignoring!!")
                break
            elif self.isFrameReady():
                if self.checkFrame():
                    if self._validate_unit_id(unit, single):
                        self._process(callback)
                        log.info(f"✅ Frame - [{data}] responded to!!")
                    else:
                        header_txt = self._header["uid"]
                        log.info(f"Not a valid unit id - {header_txt}, ignoring!!")
                        self.resetFrame()
                        break
                else:
                    log.info("Frame check failed, ignoring!!")
                    self.resetFrame()
                    break
            else:
                log.debug(f"Frame - [{data}] not ready")
                break
```

Then you would use this like this (assuming StartAsyncSerialServer worked in version 3.2 per https://github.com/pymodbus-dev/pymodbus/issues/1434):

```python
        return await StartAsyncSerialServer(
            identity=_identity,
            context=_context,
            framer=PatchedModbusRtuFramer,
            port=port,
            stopbits=stop_bits,
            bytesize=byte_size,
            parity=parity,
            baudrate=baud,
            ignore_missing_slaves=True,
        )
```

As I said, I'm not confident that this solution doesn't have other unintended side effects as I'm not fully aware of the architecture of the library.
Thanks for sharing, I will take a closer look soon and see if it can be integrated without sideeffects.

We have an unexpected 3.2.1 coming very shortly, and if possible I would like to include this.

Resolving patch:
diff --git a/pymodbus/factory.py b/pymodbus/factory.py
--- a/pymodbus/factory.py
+++ b/pymodbus/factory.py
@@ -164,7 +164,7 @@ def getFCdict(cls):
     def __init__(self):
         """Initialize the client lookup tables."""
         functions = {f.function_code for f in self.__function_table}
-        self.__lookup = self.getFCdict()
+        self.lookup = self.getFCdict()
         self.__sub_lookup = {f: {} for f in functions}
         for f in self.__sub_function_table:
             self.__sub_lookup[f.function_code][f.sub_function_code] = f
@@ -187,7 +187,7 @@ def lookupPduClass(self, function_code):
         :param function_code: The function code specified in a frame.
         :returns: The class of the PDU that has a matching `function_code`.
         """
-        return self.__lookup.get(function_code, ExceptionResponse)
+        return self.lookup.get(function_code, ExceptionResponse)
 
     def _helper(self, data):
         """Generate the correct request object from a valid request packet.
@@ -198,12 +198,12 @@ def _helper(self, data):
         :returns: The decoded request or illegal function request object
         """
         function_code = int(data[0])
-        if not (request := self.__lookup.get(function_code, lambda: None)()):
+        if not (request := self.lookup.get(function_code, lambda: None)()):
             Log.debug("Factory Request[{}]", function_code)
             request = IllegalFunctionRequest(function_code)
         else:
             fc_string = "%s: %s" % (  # pylint: disable=consider-using-f-string
-                str(self.__lookup[function_code])  # pylint: disable=use-maxsplit-arg
+                str(self.lookup[function_code])  # pylint: disable=use-maxsplit-arg
                 .split(".")[-1]
                 .rstrip('">"'),
                 function_code,
@@ -230,7 +230,7 @@ def register(self, function=None):
                 ". Class needs to be derived from "
                 "`pymodbus.pdu.ModbusRequest` "
             )
-        self.__lookup[function.function_code] = function
+        self.lookup[function.function_code] = function
         if hasattr(function, "sub_function_code"):
             if function.function_code not in self.__sub_lookup:
                 self.__sub_lookup[function.function_code] = {}
@@ -293,7 +293,7 @@ class ClientDecoder:
     def __init__(self):
         """Initialize the client lookup tables."""
         functions = {f.function_code for f in self.function_table}
-        self.__lookup = {f.function_code: f for f in self.function_table}
+        self.lookup = {f.function_code: f for f in self.function_table}
         self.__sub_lookup = {f: {} for f in functions}
         for f in self.__sub_function_table:
             self.__sub_lookup[f.function_code][f.sub_function_code] = f
@@ -304,7 +304,7 @@ def lookupPduClass(self, function_code):
         :param function_code: The function code specified in a frame.
         :returns: The class of the PDU that has a matching `function_code`.
         """
-        return self.__lookup.get(function_code, ExceptionResponse)
+        return self.lookup.get(function_code, ExceptionResponse)
 
     def decode(self, message):
         """Decode a response packet.
@@ -330,15 +330,15 @@ def _helper(self, data):
         :raises ModbusException:
         """
         fc_string = function_code = int(data[0])
-        if function_code in self.__lookup:
+        if function_code in self.lookup:
             fc_string = "%s: %s" % (  # pylint: disable=consider-using-f-string
-                str(self.__lookup[function_code])  # pylint: disable=use-maxsplit-arg
+                str(self.lookup[function_code])  # pylint: disable=use-maxsplit-arg
                 .split(".")[-1]
                 .rstrip('">"'),
                 function_code,
             )
         Log.debug("Factory Response[{}]", fc_string)
-        response = self.__lookup.get(function_code, lambda: None)()
+        response = self.lookup.get(function_code, lambda: None)()
         if function_code > 0x80:
             code = function_code & 0x7F  # strip error portion
             response = ExceptionResponse(code, ecode.IllegalFunction)
@@ -361,7 +361,7 @@ def register(self, function):
                 ". Class needs to be derived from "
                 "`pymodbus.pdu.ModbusResponse` "
             )
-        self.__lookup[function.function_code] = function
+        self.lookup[function.function_code] = function
         if hasattr(function, "sub_function_code"):
             if function.function_code not in self.__sub_lookup:
                 self.__sub_lookup[function.function_code] = {}
diff --git a/pymodbus/framer/rtu_framer.py b/pymodbus/framer/rtu_framer.py
--- a/pymodbus/framer/rtu_framer.py
+++ b/pymodbus/framer/rtu_framer.py
@@ -64,6 +64,7 @@ def __init__(self, decoder, client=None):
         self._hsize = 0x01
         self._end = b"\x0d\x0a"
         self._min_frame_size = 4
+        self.function_codes = set(self.decoder.lookup) if self.decoder else {}
 
     # ----------------------------------------------------------------------- #
     # Private Helper Functions
@@ -117,7 +118,7 @@ def resetFrame(self):
         Log.debug(
             "Resetting frame - Current Frame in buffer - {}", self._buffer, ":hex"
         )
-        self._buffer = b""
+        # self._buffer = b""
         self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}
 
     def isFrameReady(self):
@@ -191,6 +192,23 @@ def populateResult(self, result):
         result.slave_id = self._header["uid"]
         result.transaction_id = self._header["uid"]
 
+    def getFrameStart(self, slaves, broadcast, skip_cur_frame):
+        """Scan buffer for a relevant frame start."""
+        start = 1 if skip_cur_frame else 0
+        if (buf_len := len(self._buffer)) < 4:
+            return False
+        for i in range(start, buf_len - 3):  # <slave id><function code><crc 2 bytes>
+            if not broadcast and self._buffer[i] not in slaves:
+                continue
+            if self._buffer[i + 1] not in self.function_codes:
+                continue
+            if i:
+                self._buffer = self._buffer[i:]  # remove preceding trash.
+            return True
+        if buf_len > 3:
+            self._buffer = self._buffer[-3:]
+        return False
+
     # ----------------------------------------------------------------------- #
     # Public Member Functions
     # ----------------------------------------------------------------------- #
@@ -214,25 +232,26 @@ def processIncomingPacket(self, data, callback, slave, **kwargs):
         """
         if not isinstance(slave, (list, tuple)):
             slave = [slave]
+        broadcast = not slave[0]
         self.addToFrame(data)
         single = kwargs.get("single", False)
-        while True:
-            if self.isFrameReady():
-                if self.checkFrame():
-                    if self._validate_slave_id(slave, single):
-                        self._process(callback)
-                    else:
-                        header_txt = self._header["uid"]
-                        Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
-                        self.resetFrame()
-                        break
-                else:
-                    Log.debug("Frame check failed, ignoring!!")
-                    self.resetFrame()
-                    break
-            else:
+        skip_cur_frame = False
+        while self.getFrameStart(slave, broadcast, skip_cur_frame):
+            if not self.isFrameReady():
                 Log.debug("Frame - [{}] not ready", data)
                 break
+            if not self.checkFrame():
+                Log.debug("Frame check failed, ignoring!!")
+                self.resetFrame()
+                skip_cur_frame = True
+                continue
+            if not self._validate_slave_id(slave, single):
+                header_txt = self._header["uid"]
+                Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
+                self.resetFrame()
+                skip_cur_frame = True
+                continue
+            self._process(callback)
 
     def buildPacket(self, message):
         """Create a ready to send modbus packet.


Test patch:
diff --git a/test/test_client_multidrop.py b/test/test_client_multidrop.py
--- a/test/test_client_multidrop.py
+++ b/test/test_client_multidrop.py
@@ -30,21 +30,25 @@ def test_ok_frame(self, framer, callback):
         framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_called_once()
 
+    def test_ok_2frame(self, framer, callback):
+        """Test ok frame."""
+        serial_event = self.good_frame + self.good_frame
+        framer.processIncomingPacket(serial_event, callback, self.slaves)
+        assert callback.call_count == 2
+
     def test_bad_crc(self, framer, callback):
         """Test bad crc."""
         serial_event = b"\x02\x03\x00\x01\x00}\xd4\x19"  # Manually mangled crc
         framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_not_called()
 
-    def test_wrong_unit(self, framer, callback):
-        """Test frame wrong unit"""
-        serial_event = (
-            b"\x01\x03\x00\x01\x00}\xd4+"  # Frame with good CRC but other unit id
-        )
+    def test_wrong_id(self, framer, callback):
+        """Test frame wrong id"""
+        serial_event = b"\x01\x03\x00\x01\x00}\xd4+"  # Frame with good CRC but other id
         framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_not_called()
 
-    def test_big_split_response_frame_from_other_unit(self, framer, callback):
+    def test_big_split_response_frame_from_other_id(self, framer, callback):
         """Test split response."""
         # This is a single *response* from device id 1 after being queried for 125 holding register values
         # Because the response is so long it spans several serial events
@@ -70,26 +74,21 @@ def test_split_frame(self, framer, callback):
             framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_called_once()
 
-    @pytest.mark.skip
-    def test_complete_frame_trailing_data_without_unit_id(self, framer, callback):
+    def test_complete_frame_trailing_data_without_id(self, framer, callback):
         """Test trailing data."""
-        garbage = b"\x05\x04\x03"  # Note the garbage doesn't contain our unit id
+        garbage = b"\x05\x04\x03"  # without id
         serial_event = garbage + self.good_frame
         framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_called_once()
 
-    @pytest.mark.skip
-    def test_complete_frame_trailing_data_with_unit_id(self, framer, callback):
+    def test_complete_frame_trailing_data_with_id(self, framer, callback):
         """Test trailing data."""
-        garbage = (
-            b"\x05\x04\x03\x02\x01\x00"  # Note the garbage does contain our unit id
-        )
+        garbage = b"\x05\x04\x03\x02\x01\x00"  # with id
         serial_event = garbage + self.good_frame
         framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_called_once()
 
-    @pytest.mark.skip
-    def test_split_frame_trailing_data_with_unit_id(self, framer, callback):
+    def test_split_frame_trailing_data_with_id(self, framer, callback):
         """Test split frame."""
         garbage = b"\x05\x04\x03\x02\x01\x00"
         serial_events = [garbage + self.good_frame[:5], self.good_frame[5:]]
@@ -97,6 +96,30 @@ def test_split_frame_trailing_data_with_unit_id(self, framer, callback):
             framer.processIncomingPacket(serial_event, callback, self.slaves)
         callback.assert_called_once()
 
+    def test_coincidental_1(self, framer, callback):
+        """Test conincidental."""
+        garbage = b"\x02\x90\x07"
+        serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]]
+        for serial_event in serial_events:
+            framer.processIncomingPacket(serial_event, callback, self.slaves)
+        callback.assert_called_once()
+
+    def test_coincidental_2(self, framer, callback):
+        """Test conincidental."""
+        garbage = b"\x02\x10\x07"
+        serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]]
+        for serial_event in serial_events:
+            framer.processIncomingPacket(serial_event, callback, self.slaves)
+        callback.assert_called_once()
+
+    def test_coincidental_3(self, framer, callback):
+        """Test conincidental."""
+        garbage = b"\x02\x10\x07\x10"
+        serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]]
+        for serial_event in serial_events:
+            framer.processIncomingPacket(serial_event, callback, self.slaves)
+        callback.assert_called_once()
+
     def test_wrapped_frame(self, framer, callback):
         """Test wrapped frame."""
         garbage = b"\x05\x04\x03\x02\x01\x00"
@@ -107,9 +130,8 @@ def test_wrapped_frame(self, framer, callback):
         # i.e. this probably represents a case where a command came for us, but we didn't get
         # to the serial buffer in time (some other co-routine or perhaps a block on the USB bus)
         # and the master moved on and queried another device
-        callback.assert_not_called()
+        callback.assert_called_once()
 
-    @pytest.mark.skip
     def test_frame_with_trailing_data(self, framer, callback):
         """Test trailing data."""
         garbage = b"\x05\x04\x03\x02\x01\x00"
@@ -117,4 +139,31 @@ def test_frame_with_trailing_data(self, framer, callback):
         framer.processIncomingPacket(serial_event, callback, self.slaves)
 
         # We should not respond in this case for identical reasons as test_wrapped_frame
-        callback.assert_not_called()
+        callback.assert_called_once()
+
+    def test_getFrameStart(self, framer):
+        """Test getFrameStart."""
+        framer_ok = b"\x02\x03\x00\x01\x00}\xd4\x18"
+        framer._buffer = framer_ok  # pylint: disable=protected-access
+        assert framer.getFrameStart(self.slaves, False, False)
+        assert framer_ok == framer._buffer  # pylint: disable=protected-access
+
+        framer_2ok = framer_ok + framer_ok
+        framer._buffer = framer_2ok  # pylint: disable=protected-access
+        assert framer.getFrameStart(self.slaves, False, False)
+        assert framer_2ok == framer._buffer  # pylint: disable=protected-access
+        assert framer.getFrameStart(self.slaves, False, True)
+        assert framer_ok == framer._buffer  # pylint: disable=protected-access
+
+        framer._buffer = framer_ok[:2]  # pylint: disable=protected-access
+        assert not framer.getFrameStart(self.slaves, False, False)
+        assert framer_ok[:2] == framer._buffer  # pylint: disable=protected-access
+
+        framer._buffer = framer_ok[:3]  # pylint: disable=protected-access
+        assert not framer.getFrameStart(self.slaves, False, False)
+        assert framer_ok[:3] == framer._buffer  # pylint: disable=protected-access
+
+        framer_ok = b"\xF0\x03\x00\x01\x00}\xd4\x18"
+        framer._buffer = framer_ok  # pylint: disable=protected-access
+        assert not framer.getFrameStart(self.slaves, False, False)
+        assert framer._buffer == framer_ok[-3:]  # pylint: disable=protected-access
diff --git a/test/test_framers.py b/test/test_framers.py
--- a/test/test_framers.py
+++ b/test/test_framers.py
@@ -143,7 +143,6 @@ def test_rtu_reset_framer(rtu_framer, data):  # pylint: disable=redefined-outer-
         "len": 0,
         "crc": b"\x00\x00",
     }
-    assert rtu_framer._buffer == b""  # pylint: disable=protected-access
 
 
 @pytest.mark.parametrize(
@@ -255,7 +254,7 @@ def test_populate_result(rtu_framer):  # pylint: disable=redefined-outer-name
         (
             b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD",
             16,
-            True,
+            False,
             False,
         ),  # incorrect slave id
         (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", 17, False, True),
diff --git a/test/xmulti_rtu1 b/test/xmulti_rtu1
new file mode 100644
--- /dev/null
+++ b/test/xmulti_rtu1
@@ -0,0 +1,80 @@
+import logging
+
+from pymodbus.framer.rtu_framer import ModbusRtuFramer
+from pymodbus.utilities import checkCRC
+import struct
+
+log = logging.getLogger(__name__)
+
+
+class PatchedModbusRtuFramer(ModbusRtuFramer):
+    def isFrameIntendedForUs(self, units: list[int]):
+        try:
+            unit = self._buffer[0]
+            return unit in units
+        except IndexError as e:
+            return True
+
+    def advanceToNextOccurrenceOfUnit(self, units: list[int]):
+        j = None
+        for i, b in enumerate(self._buffer):
+            if b in units and i > 0:
+                j = i
+                break
+
+        if j:
+            self._buffer = self._buffer[j:]
+        else:
+            self._buffer = b""
+
+        self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}
+        return len(self._buffer)
+
+    def checkFrame(self):
+        try:
+            self.populateHeader()
+            frame_size = self._header["len"]
+
+            if len(self._buffer) > frame_size:
+                # This means there are bytes *after* a valid frame intended to us.
+                # If there are bytes after that means we've probably de-sychronized
+                # and the master has considered us as having timed out and moved on.
+                # That or there is somehow a random sequence of bytes which looks like
+                # a real command to us and magically a crc.
+                # In either case, we must not respond.
+                return False
+
+            data = self._buffer[: frame_size - 2]
+            crc = self._header["crc"]
+            crc_val = (int(crc[0]) << 8) + int(crc[1])
+            return checkCRC(data, crc_val)
+        except (IndexError, KeyError, struct.error):
+            return False
+
+    def processIncomingPacket(self, data, callback, unit, **kwargs):
+        if not isinstance(unit, (list, tuple)):
+            unit = [unit]
+        self.addToFrame(data)
+        single = kwargs.get("single", False)
+        while True:
+            if not self.isFrameIntendedForUs(unit):
+                if self.advanceToNextOccurrenceOfUnit(unit) == 0:
+                    log.info(f"❌ Frame - [{data}] not intended for us, ignoring!!")
+                    break
+            elif self.isFrameReady():
+                if self.checkFrame():
+                    if self._validate_unit_id(unit, single):
+                        self._process(callback)
+                        log.info(f"✅ Frame - [{data}] responded to!!")
+                    else:
+                        header_txt = self._header["uid"]
+                        log.info(f"Not a valid unit id - {header_txt}, ignoring!!")
+                        self.resetFrame()
+                        break
+                else:
+                    log.info("Frame check failed, ignoring!!")
+                    if self.advanceToNextOccurrenceOfUnit(unit) == 0:
+                        break
+            else:
+                log.info(f"Frame - [{data}] not ready")
+                break
diff --git a/test/xmulti_rtu2 b/test/xmulti_rtu2
new file mode 100644
--- /dev/null
+++ b/test/xmulti_rtu2
@@ -0,0 +1,104 @@
+import logging
+import struct
+
+from pymodbus.framer.rtu_framer import ModbusRtuFramer
+from pymodbus.utilities import checkCRC
+
+log = logging.getLogger(__name__)
+
+
+class PatchedModbusRtuFramer(ModbusRtuFramer):
+    def isFrameIntendedForUs(self, units: list[int]):
+        try:
+            # Always validate that the unit id and function code
+            unit = self._buffer[0]
+            fc = self._buffer[1]
+
+            valid_unit_id = unit in units
+            valid_function_code = fc in [2, 1, 5, 15, 4, 3, 6, 16, 43]
+
+            if fc in [15, 16]:
+                # These commands will have arbitrary payload lengths
+                # Further validation is beneficial
+                n_registers = int.from_bytes(self._buffer[4:6], byteorder="big")
+                n_bytes = self._buffer[6]
+
+                expected_number_of_bytes = n_registers * 2
+
+                valid_number_of_registers = n_registers <= 123
+                valid_number_of_bytes = n_bytes == expected_number_of_bytes
+                return (
+                    valid_unit_id
+                    and valid_function_code
+                    and valid_number_of_registers
+                    and valid_number_of_bytes
+                )
+            return valid_unit_id and valid_function_code
+
+        except IndexError as e:
+            return True
+
+    def advanceToNextOccurrenceOfUnit(self, units: list[int]):
+        j = None
+        for i, b in enumerate(self._buffer):
+            if b in units and i > 0:
+                j = i
+                break
+
+        if j:
+            log.debug("⏭")
+            self._buffer = self._buffer[j:]
+        else:
+            log.debug("🗑")
+            self._buffer = b""
+
+        self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}
+        return len(self._buffer)
+
+    def checkFrame(self):
+        try:
+            self.populateHeader()
+            frame_size = self._header["len"]
+
+            if len(self._buffer) > frame_size:
+                # This means there are bytes *after* a valid frame intended to us.
+                # If there are bytes after that means we've probably de-sychronized
+                # and the master has considered us as having timed out and moved on.
+                # That or there is somehow a random sequence of bytes which looks like
+                # a real command to us and magically a crc.
+                # In either case, we must not respond.
+                return False
+
+            data = self._buffer[: frame_size - 2]
+            crc = self._header["crc"]
+            crc_val = (int(crc[0]) << 8) + int(crc[1])
+            return checkCRC(data, crc_val)
+        except (IndexError, KeyError, struct.error):
+            return False
+
+    def processIncomingPacket(self, data, callback, unit, **kwargs):
+        log.debug(f"🌟 {data.hex(':')}")
+        if not isinstance(unit, (list, tuple)):
+            unit = [unit]
+        self.addToFrame(data)
+        single = kwargs.get("single", False)
+        while True:
+            log.debug(f"🧪 {self._buffer.hex(':')}")
+            if not self.isFrameIntendedForUs(unit):
+                if self.advanceToNextOccurrenceOfUnit(unit) == 0:
+                    break
+            elif self.isFrameReady():
+                if self.checkFrame():
+                    if self._validate_unit_id(unit, single):
+                        log.debug(f"✅ {self._buffer.hex(':')}")
+                        self._process(callback)
+                    else:
+                        self.resetFrame()
+                        break
+                else:
+                    log.debug("Frame check failed, ignoring!!")
+                    if self.advanceToNextOccurrenceOfUnit(unit) == 0:
+                        break
+            else:
+                log.debug(f"⌛ {self._buffer.hex(':')}")
+                break
diff --git a/test/xmulti_test1 b/test/xmulti_test1
new file mode 100644
--- /dev/null
+++ b/test/xmulti_test1
@@ -0,0 +1,117 @@
+from unittest import mock
+
+import pytest
+from pymodbus.server.async_io import ServerDecoder
+
+from fix_pymodbus.patched_modbus_rtu_framer import PatchedModbusRtuFramer
+
+
+@pytest.fixture
+def framer() -> PatchedModbusRtuFramer:
+    return PatchedModbusRtuFramer(ServerDecoder())
+
+
+@pytest.fixture
+def callback() -> mock.Mock:
+    return mock.Mock()
+
+
+expected_unit = [2]
+good_frame = b"\x02\x03\x00\x01\x00}\xd4\x18"
+
+
+def test_complete_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_bad_crc(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = b"\x02\x03\x00\x01\x00}\xd4\x19"  # Manually mangled crc
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_complete_frame_wrong_unit(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = (
+        b"\x01\x03\x00\x01\x00}\xd4+"  # Frame with good CRC but other unit id
+    )
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_big_split_response_frame_from_other_unit(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    # This is a single *response* from device id 1 after being queried for 125 holding register values
+    # Because the response is so long it spans several serial events
+    serial_events = [
+        b"\x01\x03\xfa\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00Dz\x00\x00C\x96\x00\x00",
+        b"?\x05\x1e\xb8DH\x00\x00D\x96\x00\x00D\xfa\x00\x00DH\x00\x00D\x96\x00\x00D\xfa\x00\x00DH\x00",
+        b"\x00D\x96\x00\x00D\xfa\x00\x00B\x96\x00\x00B\xb4\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00N,",
+    ]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_split_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_events = [good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_trailing_data_without_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03"  # Note the garbage doesn't contain our unit id
+    serial_event = garbage + good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_trailing_data_with_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03\x02\x01\x00"  # Note the garbage does contain our unit id
+    serial_event = garbage + good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_split_frame_trailing_data_with_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_events = [garbage + good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_wrapped_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_event = garbage + good_frame + garbage
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+
+    # We probably should not respond in this case; in this case we've likely become desynchronized
+    # i.e. this probably represents a case where a command came for us, but we didn't get
+    # to the serial buffer in time (some other co-routine or perhaps a block on the USB bus)
+    # and the master moved on and queried another device
+    callback.assert_not_called()
+
+
+def test_frame_with_trailing_data(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_event = good_frame + garbage
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+
+    # We should not respond in this case for identical reasons as test_wrapped_frame
+    callback.assert_not_called()
diff --git a/test/xmulti_test2 b/test/xmulti_test2
new file mode 100644
--- /dev/null
+++ b/test/xmulti_test2
@@ -0,0 +1,133 @@
+from unittest import mock
+
+import pytest
+from pymodbus.server.async_io import ServerDecoder
+
+from fix_pymodbus.patched_modbus_rtu_framer import PatchedModbusRtuFramer
+
+
+@pytest.fixture
+def framer() -> PatchedModbusRtuFramer:
+    return PatchedModbusRtuFramer(ServerDecoder())
+
+
+@pytest.fixture
+def callback() -> mock.Mock:
+    return mock.Mock()
+
+
+expected_unit = [2]
+good_frame = b"\x02\x03\x00\x01\x00}\xd4\x18"
+
+
+def test_complete_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_bad_crc(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = b"\x02\x03\x00\x01\x00}\xd4\x19"  # Manually mangled crc
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_complete_frame_wrong_unit(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_event = (
+        b"\x01\x03\x00\x01\x00}\xd4+"  # Frame with good CRC but other unit id
+    )
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_big_split_response_frame_from_other_unit(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    # This is a single *response* from device id 1 after being queried for 125 holding register values
+    # Because the response is so long it spans several serial events
+    serial_events = [
+        b"\x01\x03\xfa\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00\xc4y\xc0\x00Dz\x00\x00C\x96\x00\x00",
+        b"?\x05\x1e\xb8DH\x00\x00D\x96\x00\x00D\xfa\x00\x00DH\x00\x00D\x96\x00\x00D\xfa\x00\x00DH\x00",
+        b"\x00D\x96\x00\x00D\xfa\x00\x00B\x96\x00\x00B\xb4\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+        b"\x00\x00\x00\x00\x00\x00\x00N,",
+    ]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_not_called()
+
+
+def test_split_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    serial_events = [good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_trailing_data_without_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03"  # Note the garbage doesn't contain our unit id
+    serial_event = garbage + good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_complete_frame_trailing_data_with_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03\x02\x01\x00"  # Note the garbage does contain our unit id
+    serial_event = garbage + good_frame
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_split_frame_trailing_data_with_unit_id(
+    framer: PatchedModbusRtuFramer, callback: mock.Mock
+):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_events = [garbage + good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_coincidental_1(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x02\x14\x07"
+    serial_events = [garbage, good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_coincidental_2(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x02\x10\x07"
+    serial_events = [garbage, good_frame[:5], good_frame[5:]]
+    for serial_event in serial_events:
+        framer.processIncomingPacket(serial_event, callback, expected_unit)
+    callback.assert_called_once()
+
+
+def test_wrapped_frame(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_event = garbage + good_frame + garbage
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+
+    # We probably should not respond in this case; in this case we've likely become desynchronized
+    # i.e. this probably represents a case where a command came for us, but we didn't get
+    # to the serial buffer in time (some other co-routine or perhaps a block on the USB bus)
+    # and the master moved on and queried another device
+    callback.assert_not_called()
+
+
+def test_frame_with_trailing_data(framer: PatchedModbusRtuFramer, callback: mock.Mock):
+    garbage = b"\x05\x04\x03\x02\x01\x00"
+    serial_event = good_frame + garbage
+    framer.processIncomingPacket(serial_event, callback, expected_unit)
+
+    # We should not respond in this case for identical reasons as test_wrapped_frame
+    callback.assert_not_called()