"""End-to-end server tests over real TCP sockets.""" from __future__ import annotations import asyncio import pytest from wago_sim.mbap import build_frame from wago_sim.pdu import ( ExceptionResponse, FC_READ_COILS, FC_READ_DISCRETE_INPUTS, FC_READ_HOLDING_REGISTERS, FC_READ_INPUT_REGISTERS, FC_WRITE_MULTIPLE_REGISTERS, FC_WRITE_SINGLE_COIL, FC_WRITE_SINGLE_REGISTER, ) async def _start(app, port=15020): app.server.host, app.server.port = "127.0.0.1", port await app.server.start() await asyncio.sleep(0.05) return port async def _roundtrip(reader, writer, txn, request_pdu): writer.write(build_frame(txn, 1, request_pdu)) await writer.drain() header = await reader.readexactly(7) length = int.from_bytes(header[4:6], "big") body = await reader.readexactly(length - 1) return header, body class TestProtocolRoundtrip: async def test_read_constants(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) # FC3 read 9 words at 0x2000 pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x2000).to_bytes(2, "big") + (9).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 7, pdu) assert body[0] == FC_READ_HOLDING_REGISTERS assert body[1] == 18 words = [int.from_bytes(body[2 + 2 * i:4 + 2 * i], "big") for i in range(9)] assert words == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555, 0x7FFF, 0x8000, 0x3FFF, 0x4000] writer.close() await app.server.stop() async def test_write_and_readback(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) # FC6 write single register 0x1234 to address 0 pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + (0).to_bytes(2, "big") + (0x1234).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 1, pdu) assert body == bytes([FC_WRITE_SINGLE_REGISTER]) + (0).to_bytes(2, "big") + (0x1234).to_bytes(2, "big") # FC4 read back at 0x0200 mirror pdu = bytes([FC_READ_INPUT_REGISTERS]) + (0x0200).to_bytes(2, "big") + (1).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 2, pdu) # response PDU: [FC, byte_count, hi, lo] assert int.from_bytes(body[2:4], "big") == 0x1234 writer.close() await app.server.stop() async def test_coil_write_and_read(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) # FC5 write coil address 3 ON pdu = bytes([FC_WRITE_SINGLE_COIL]) + (3).to_bytes(2, "big") + b"\xFF\x00" _, body = await _roundtrip(reader, writer, 3, pdu) assert body[0] == FC_WRITE_SINGLE_COIL # FC1 read coils at 0x0200 (output mirror) pdu = bytes([FC_READ_COILS]) + (0x0200).to_bytes(2, "big") + (8).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 4, pdu) assert body[2] & (1 << 3) writer.close() await app.server.stop() async def test_illegal_address_exception(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x0100).to_bytes(2, "big") + (1).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 5, pdu) assert body[0] == FC_READ_HOLDING_REGISTERS | 0x80 assert body[1] == 0x02 writer.close() await app.server.stop() async def test_unsupported_function(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) pdu = bytes([0x07]) + b"\x00\x00" _, body = await _roundtrip(reader, writer, 6, pdu) assert body[0] == 0x87 assert body[1] == 0x01 assert app.stats.bad_function == 1 writer.close() await app.server.stop() async def test_statistics_after_errors(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) # two illegal-address reads for txn in (10, 11): pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x0300).to_bytes(2, "big") + (1).to_bytes(2, "big") await _roundtrip(reader, writer, txn, pdu) assert app.stats.bad_address == 2 # read statistics block via FC3 at 0x1029 pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x1029).to_bytes(2, "big") + (9).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 12, pdu) words = [int.from_bytes(body[2 + 2 * i:4 + 2 * i], "big") for i in range(9)] assert words[4] == 2 # BadAddress assert words[8] == 3 # ModTcpMessageCounter writer.close() await app.server.stop() async def test_watchdog_expiry_blocks_process_data(self, app): await _start(app) reader, writer = await asyncio.open_connection("127.0.0.1", 15020) # configure watchdog: 1 unit = 100 ms, monitor FC3 (bit 2) for txn, (addr, val) in enumerate([(0x1000, 1), (0x1001, 0x0004)], start=20): pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + addr.to_bytes(2, "big") + val.to_bytes(2, "big") await _roundtrip(reader, writer, txn, pdu) # trigger with alternating values pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + (0x1003).to_bytes(2, "big") + (1).to_bytes(2, "big") await _roundtrip(reader, writer, 30, pdu) # let it expire: 100ms without masked FC3 await asyncio.sleep(0.2) # process data read must now answer 0x04 pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0).to_bytes(2, "big") + (1).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 31, pdu) assert body[0] == FC_READ_HOLDING_REGISTERS | 0x80 assert body[1] == 0x04 # watchdog registers remain accessible: status reads expired (2) pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x1006).to_bytes(2, "big") + (1).to_bytes(2, "big") _, body = await _roundtrip(reader, writer, 32, pdu) # response PDU: [FC, byte_count, hi, lo] assert int.from_bytes(body[2:4], "big") == 2 writer.close() await app.server.stop()