🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-03 09:46:11 +02:00
commit 973d619cee
34 changed files with 5787 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
"""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()