Repository: pymodbus-dev/pymodbus

Issue:
AsyncModbusSerialClient crashes when slave_id is not present
### Versions

- Python: 3.11
- OS: Windows 11 (ver 22H2, 22621.2428)
- Pymodbus: 3.6.0dev
- Modbus Hardware (if used): USB Serial

### Pymodbus Specific

- Client: rtu - async

### Description

Using AsyncModbusSerialClient, attempting to read from a slave ID that is not present on the serial bus causes an AttributeError

### Code and Logs

```python
import asyncio
import sys

import pymodbus
from pymodbus.client import AsyncModbusSerialClient, ModbusSerialClient

# ----------------------------------------------------------------------

async def event_main_loop():
    mbparams = dict(
        timeout=0.5,
        retries=0,
        no_resend_on_retry=True,
        reconnect_delay=0,
    )
    mbdev = AsyncModbusSerialClient("COM6", baudrate=19200, **mbparams)
    await mbdev.connect()

    try:
        res = await mbdev.read_holding_registers(0, 3, slave=1)
        print("read result: ", res)
    except pymodbus.exceptions.ModbusException as e:
        print("Blah: ", e)
        res = None

    if res and not res.isError():
        print("Read returned:", res.registers)

    mbdev.close()

# - - - - - - - - - - - - - - - - - - - - - - - - - -

def event_main_loop_sync():
    mbparams = dict(
        timeout=0.5,
        retries=0,
        no_resend_on_retry=True,
        reconnect_delay=0,
    )
    mbdev = ModbusSerialClient("COM6", baudrate=19200, **mbparams)
    mbdev.connect()

    try:
        res = mbdev.read_holding_registers(0, 3, slave=1)
        print("read result: ", res)
    except pymodbus.exceptions.ModbusException as e:
        print("Blah: ", e)
        res = None

    if res and not res.isError():
        print("Read returned:", res.registers)

    mbdev.close()

if __name__ == "__main__":
    print(f"Python version: {sys.version_info}, {pymodbus.__version_full__}")
    pymodbus.pymodbus_apply_logging_config(log_file_name="t-mbserial.log")
    #event_main_loop_sync()
    asyncio.run(event_main_loop())

```

# Log:
```
Python version: sys.version_info(major=3, minor=11, micro=5, releaselevel='final', serial=0), [pymodbus, version 3.6.0dev]
2023-10-22 23:33:59,440 DEBUG logging:103 Connecting to COM6.
2023-10-22 23:33:59,440 DEBUG logging:103 Connecting comm
2023-10-22 23:33:59,456 DEBUG logging:103 Connected to comm
2023-10-22 23:33:59,456 DEBUG logging:103 callback_connected called
2023-10-22 23:33:59,456 DEBUG logging:103 send: 0x1 0x3 0x0 0x0 0x0 0x3 0x5 0xcb
2023-10-22 23:33:59,456 DEBUG logging:103 Adding transaction 1
2023-10-22 23:33:59,958 DEBUG logging:103 Connection lost comm due to Server not responding
2023-10-22 23:34:00,068 DEBUG logging:103 Connection lost comm due to None
2023-10-22 23:34:00,068 DEBUG logging:103 Getting transaction 1
Traceback (most recent call last):
  File "C:\ctw\t-mbserial-async-noslave\t-mbserial-async-noslave.py", line 61, in <module>
    asyncio.run(event_main_loop())
  File "c:\ctw\0-dev\Python311\Lib\asyncio\runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "c:\ctw\0-dev\Python311\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "c:\ctw\0-dev\Python311\Lib\asyncio\base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "C:\ctw\t-mbserial-async-noslave\t-mbserial-async-noslave.py", line 22, in event_main_loop
    res = await mbdev.read_holding_registers(0, 3, slave=1)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\ctw\t-mbserial-async-noslave\pymodbus\client\base.py", line 207, in async_execute
    self.close(reconnect=True)
  File "C:\ctw\t-mbserial-async-noslave\pymodbus\client\serial.py", line 106, in close
    super().close(reconnect=reconnect)
  File "C:\ctw\t-mbserial-async-noslave\pymodbus\client\base.py", line 154, in close
    self.connection_lost(asyncio.TimeoutError("Server not responding"))
  File "C:\ctw\t-mbserial-async-noslave\pymodbus\transport\transport.py", line 300, in connection_lost
    self.transport_close(intern=True)
  File "C:\ctw\t-mbserial-async-noslave\pymodbus\transport\transport.py", line 419, in transport_close
    self.transport.close()
    ^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'close'
```

When using the sync version, the code does not crash:
```
Python version: sys.version_info(major=3, minor=11, micro=5, releaselevel='final', serial=0), [pymodbus, version 3.6.0dev]
2023-10-22 23:35:35,780 DEBUG logging:103 Current transaction state - IDLE
2023-10-22 23:35:35,780 DEBUG logging:103 Running transaction 1
2023-10-22 23:35:35,780 DEBUG logging:103 SEND: 0x1 0x3 0x0 0x0 0x0 0x3 0x5 0xcb
2023-10-22 23:35:35,780 DEBUG logging:103 New Transaction state "SENDING"
2023-10-22 23:35:35,780 DEBUG logging:103 Changing transaction state from "SENDING" to "WAITING FOR REPLY"
2023-10-22 23:35:36,902 DEBUG logging:103 Transaction failed. (Modbus Error: [Invalid Message] No response received, expected at least 4 bytes (0 received))
2023-10-22 23:35:36,902 DEBUG logging:103 Processing:
2023-10-22 23:35:36,902 DEBUG logging:103 Getting transaction 1
2023-10-22 23:35:36,902 DEBUG logging:103 Changing transaction state from "PROCESSING REPLY" to "TRANSACTION_COMPLETE"
read result:  Modbus Error: [Input/Output] Modbus Error: [Invalid Message] No response received, expected at least 4 bytes (0 received)
```

Note: the same issue happens with the current latest pymodbus v3.5.4.



Discussion:
Sorry I read your bug report wrong, this is clearly a bug. We will look into it.
Thank you for the test code; it made things very easy to bisect to 39177d72f130cb4d72138a0af41d677497cdb217.

Resolving patch:
diff --git a/pymodbus/transport/transport_serial.py b/pymodbus/transport/transport_serial.py
--- a/pymodbus/transport/transport_serial.py
+++ b/pymodbus/transport/transport_serial.py
@@ -51,8 +51,9 @@ def close(self, exc=None):
             self.async_loop.remove_reader(self.sync_serial.fileno())
         self.sync_serial.close()
         self.sync_serial = None
-        with contextlib.suppress(Exception):
-            self._protocol.connection_lost(exc)
+        if exc:
+            with contextlib.suppress(Exception):
+                self._protocol.connection_lost(exc)
 
     def write(self, data) -> None:
         """Write some data to the transport."""


Test patch: