From e1f9c68fbeb1688b40beb8ddd07d9b8a16db1ed5 Mon Sep 17 00:00:00 2001 From: Carlo Perassi Date: Fri, 30 Oct 2026 18:42:48 +0100 Subject: [PATCH] Answer ILLEGAL_FUNCTION for a request with a reserved function code Function codes 128 to 255 are reserved for exception responses, so a request using one is an illegal function (exception 01). The server decoder took such a request for an exception response, then failed in datastore_update and answered SLAVE_FAILURE (04). Two tests decoded an exception response with the server decoder; they now use the client decoder, which is the side that receives them. Co-Authored-By: Claude Opus 5.5 --- pymodbus/pdu/decoders.py | 5 +++++ test/framer/test_extras.py | 2 +- test/pdu/test_decoders.py | 7 +++++++ test/pdu/test_pdu.py | 2 +- test/server/test_server_asyncio.py | 8 ++++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pymodbus/pdu/decoders.py b/pymodbus/pdu/decoders.py index ba7d5b00c..d0114684b 100644 --- a/pymodbus/pdu/decoders.py +++ b/pymodbus/pdu/decoders.py @@ -68,6 +68,11 @@ def decode(self, frame: bytes) -> ModbusPDU: function_code = 0 try: if (function_code := int(frame[0])) > 0x80: + if self.is_server: + raise ModbusIOException( + f"Reserved function code {function_code}", + function_code=function_code, + ) pdu_exp = ExceptionResponse(function_code & 0x7F) pdu_exp.from_frame(False, frame) return pdu_exp diff --git a/test/framer/test_extras.py b/test/framer/test_extras.py index bc234b358..c74dc48b9 100755 --- a/test/framer/test_extras.py +++ b/test/framer/test_extras.py @@ -142,7 +142,7 @@ def test_ascii_process_incoming_packets(self): def test_rtu_decode_exception(self): """Test that the RTU framer can decode errors.""" msg = b"\x00\x90\x02\x9c\x01" - _, pdu = self._rtu.handleFrame(msg, 0, 0) + _, pdu = FramerRTU(DecodePDU(False)).handleFrame(msg, 0, 0) assert pdu def test_tcp_framer_skip_wrong_dev_id_then_decode(self): diff --git a/test/pdu/test_decoders.py b/test/pdu/test_decoders.py index 7aa9f2290..0208cf994 100644 --- a/test/pdu/test_decoders.py +++ b/test/pdu/test_decoders.py @@ -132,6 +132,13 @@ def test_server_decode(self, code, frame): assert pdu assert pdu.function_code == code + @pytest.mark.parametrize(("code", "frame"), list(exceptions)) + def test_server_decode_reserved_function_code(self, code, frame): + """A request may not use a function code reserved for exception responses.""" + with pytest.raises(ModbusIOException) as exc: + self.server.decode(frame) + assert exc.value.fcode == code + @pytest.mark.parametrize(("frame"), [b"", b"NO FRAME"]) @pytest.mark.parametrize(("decoder"), [server, client]) def test_decode_bad_frame(self, decoder, frame): diff --git a/test/pdu/test_pdu.py b/test/pdu/test_pdu.py index 8c11e5873..a5d2fb263 100644 --- a/test/pdu/test_pdu.py +++ b/test/pdu/test_pdu.py @@ -30,7 +30,7 @@ async def test_error_methods(self): """Test all error methods.""" result = self.exception.x_encode() assert result == b"\x01" - DecodePDU(True).decode(b"\x81" + result) + DecodePDU(False).decode(b"\x81" + result) assert self.exception.exception_code == 1 self.exception.x_decode(b"") diff --git a/test/server/test_server_asyncio.py b/test/server/test_server_asyncio.py index bea97b927..604c12565 100755 --- a/test/server/test_server_asyncio.py +++ b/test/server/test_server_asyncio.py @@ -266,6 +266,14 @@ async def test_async_tcp_server_read_count_above_limit(self): await asyncio.wait_for(BasicClient.done, timeout=0.1) assert BasicClient.received_data == b"\x00\x00\x00\x00\x00\x03\x01\x83\x03" + async def test_async_tcp_server_reserved_function_code(self): + """A function code reserved for exception responses must answer 0x01.""" + BasicClient.data = b"\x00\x00\x00\x00\x00\x04\x01\x81\x00\x00" + await self.start_server() + await self.connect_server() + await asyncio.wait_for(BasicClient.done, timeout=0.1) + assert BasicClient.received_data == b"\x00\x00\x00\x00\x00\x03\x01\x81\x01" + async def test_async_server_trace_connect_disconnect(self): """Test connect/disconnect trace handler.""" trace_connect = mock.Mock()