🎉 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
+28
View File
@@ -0,0 +1,28 @@
"""Shared fixtures for the simulator test suite."""
from __future__ import annotations
import pytest
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DI_16, DO_8, DO_16, AI_2, AO_2
@pytest.fixture
def pump_modules():
return [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
@pytest.fixture
def app(pump_modules):
return build_app(modules=pump_modules)
@pytest.fixture
def datastore(app):
return app.datastore
@pytest.fixture
def node(app):
return app.node
+157
View File
@@ -0,0 +1,157 @@
"""Datastore tests: WAGO register-map areas, mirrors, bit addressing."""
from __future__ import annotations
import pytest
from wago_sim.pdu import EXC_ILLEGAL_DATA_ADDRESS, ExceptionResponse
def _is_exc(resp) -> bool:
return isinstance(resp, ExceptionResponse)
class TestWordRead:
def test_input_area1_readable(self, datastore):
datastore.set_input_word(0, 0xBEEF)
assert datastore.read_words(0x0000, 1) == [0xBEEF]
def test_input_area1_upper_edge(self, datastore):
datastore.set_input_word(255, 0x1234)
assert datastore.read_words(0x00FF, 1) == [0x1234]
def test_hole_0x0100_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x0100, 1))
def test_output_readback_area1(self, datastore):
result = datastore.write_words(0x0000, [0xABCD])
assert result is None
assert datastore.read_words(0x0200, 1) == [0xABCD]
def test_hole_0x0300_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x0300, 1))
def test_config_area_readable(self, datastore):
# 0x2002 must return the byte-order constant 0x1234
assert datastore.read_words(0x2002, 1) == [0x1234]
def test_input_area2(self, datastore):
datastore.set_input_word(256, 0x00FF) # %IW256 -> 0x6000
assert datastore.read_words(0x6000, 1) == [0x00FF]
def test_output_readback_area2(self, datastore):
assert datastore.write_words(0x6000, [0x5A5A]) is None
assert datastore.read_words(0x7000, 1) == [0x5A5A]
def test_hole_0x62FC_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x62FC, 1))
def test_area_end_0x72FB(self, datastore):
assert datastore.write_words(0x6000 + 763, [1]) is None
assert datastore.read_words(0x7000 + 763, 1) == [1]
def test_beyond_0x72FC_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x72FC, 1))
def test_spanning_areas_rejected(self, datastore):
# a read crossing from input area 1 into the 0x0100 hole must fail
assert _is_exc(datastore.read_words(0x00FE, 4))
class TestWordWrite:
def test_write_area1(self, datastore):
assert datastore.write_words(0x0000, [0xFFFF]) is None
assert datastore.output_words[0] == 0xFFFF
def test_write_mirror_area1(self, datastore):
assert datastore.write_words(0x0200, [0x7777]) is None
assert datastore.output_words[0] == 0x7777
def test_write_config_area_watchdog_time(self, datastore):
assert datastore.write_words(0x1000, [10]) is None
def test_write_hole_0x0100_illegal(self, datastore):
assert _is_exc(datastore.write_words(0x0100, [1]))
def test_write_input_area_illegal(self, datastore):
# 0x0300 hole on write path
assert _is_exc(datastore.write_words(0x0300, [1]))
# reading input image via write FC6 at 0x0200 goes to outputs, not inputs
assert _is_exc(datastore.write_words(0x62FC, [1]))
def test_word_write_limit_100(self, datastore):
values = [1] * 101
resp = datastore.write_words(0x0000, values)
assert _is_exc(resp)
def test_word_read_limit_125(self, datastore):
assert _is_exc(datastore.read_words(0x0000, 126))
def test_mask_write(self, datastore):
assert datastore.write_words(0x0000, [0x12]) is None
result = datastore.mask_write_word(0x0000, and_mask=0xF2, or_mask=0x25)
assert result == ((0x12 & 0xF2) | (0x25 & ~0xF2 & 0xFFFF))
assert datastore.output_words[0] == result
def test_mask_write_illegal_address(self, datastore):
assert _is_exc(datastore.mask_write_word(0x0100, 0xFF, 0x00))
class TestBits:
def test_read_discrete_input_area1(self, datastore):
datastore.set_digital_input_channel(4, True)
assert datastore.read_bits(3, 1) == [True]
def test_read_coil_area1_mirror(self, datastore):
assert datastore.write_bits(0x0200, [True, False, True]) is None
assert datastore.read_bits(0x0200, 3) == [True, False, True]
def test_write_coil_area1(self, datastore):
# bit writes at 0x0000-0x01FF target OUTPUTS (Table 122), but reads
# there return INPUTS (Table 121) — readback uses the 0x0200 mirror
assert datastore.write_bits(0x0000, [True] * 8) is None
assert datastore.digital_output_channel(1) is True
assert datastore.read_bits(0x0200, 8) == [True] * 8
def test_digital_channel_addressing(self, datastore):
# channel 47 -> bit address 46 (manual §11.2.4)
datastore.set_digital_input_channel(47, True)
assert datastore.read_bits(46, 1) == [True]
def test_bit_area2_input(self, datastore):
# DI 513/514 live in input area 2 (0x8000+); digital storage starts
# after the analog words — default offset 0, so channels 513/514 map
# to storage word 32/33 via the global bit index 512/513
datastore.set_digital_input_channel(513, True)
datastore.set_digital_input_channel(514, True)
assert datastore.read_bits(0x8000 + 1, 1) == [True]
def test_bit_illegal_ranges(self, datastore):
assert _is_exc(datastore.read_bits(0x0400, 1))
assert _is_exc(datastore.read_bits(0x35F8, 1))
assert _is_exc(datastore.write_bits(0x0400, [True]))
assert _is_exc(datastore.read_bits(0x85F8, 1))
assert _is_exc(datastore.read_bits(0x95F8, 1))
def test_bit_write_limit_800(self, datastore):
assert _is_exc(datastore.write_bits(0x0000, [True] * 801))
def test_bit_read_limit_2000(self, datastore):
assert _is_exc(datastore.read_bits(0x0000, 2001))
class TestWatchdogReaction:
def test_clear_outputs(self, datastore):
assert datastore.write_words(0x0000, [0xFFFF]) is None
assert datastore.write_bits(0x0000, [True] * 16) is None
datastore.clear_outputs()
assert datastore.output_words == [0] * len(datastore.output_words)
class TestListeners:
def test_listener_notified_on_write(self, datastore):
calls = []
datastore.add_listener(lambda: calls.append(1))
datastore.write_words(0x0000, [1])
datastore.write_bits(0x0000, [True])
assert len(calls) == 2
+87
View File
@@ -0,0 +1,87 @@
"""Integration test: pymodbus as an independent Modbus TCP master.
Verifies the simulator against a third-party client implementation
(rather than our own codec) — the strongest interop check for FAT use.
Uses the pymodbus 3.15 async client API (device_id= keyword).
"""
from __future__ import annotations
import asyncio
import pytest
pymodbus = pytest.importorskip("pymodbus")
from pymodbus.client import AsyncModbusTcpClient # noqa: E402
async def _start(app, port=15021):
app.server.host, app.server.port = "127.0.0.1", port
await app.server.start()
await asyncio.sleep(0.05)
class TestPymodbusInterop:
async def test_constants_via_pymodbus(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
rr = await client.read_holding_registers(0x2000, count=9, device_id=1)
assert not rr.isError()
assert rr.registers == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555,
0x7FFF, 0x8000, 0x3FFF, 0x4000]
finally:
client.close()
await app.server.stop()
async def test_write_read_process_image(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
# write holding register 0 (-> output %QW0)
await client.write_register(0, 0x1234, device_id=1)
# read back via the input-register mirror at +0x0200
rr = await client.read_input_registers(0x0200, count=1, device_id=1)
assert rr.registers == [0x1234]
# coil write & read-back mirror at 0x0200
await client.write_coil(3, True, device_id=1)
rr = await client.read_coils(0x0200, count=8, device_id=1)
assert rr.bits[3] is True
finally:
client.close()
await app.server.stop()
async def test_illegal_address_exception(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
rr = await client.read_holding_registers(0x0100, count=1, device_id=1)
assert rr.isError()
assert rr.exception_code == 0x02
finally:
client.close()
await app.server.stop()
async def test_read_write_multiple_registers(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
# FC23: read the constant 0x1234 at 0x2002 while writing %QW0
rr = await client.readwrite_registers(
read_address=0x2002, read_count=1,
write_address=0x0000, values=[0xBEEF],
device_id=1,
)
assert not rr.isError()
assert rr.registers == [0x1234]
# the write part landed in the output image -> mirror at 0x0200
rr = await client.read_input_registers(0x0200, count=1, device_id=1)
assert rr.registers == [0xBEEF]
finally:
client.close()
await app.server.stop()
+70
View File
@@ -0,0 +1,70 @@
"""MBAP codec tests."""
from __future__ import annotations
import pytest
from wago_sim.mbap import (
MBAP_HEADER_SIZE,
MbapError,
MbapFrameReader,
MbapHeader,
build_frame,
)
def test_header_roundtrip():
header = MbapHeader(transaction_id=0x1234, protocol_id=0, length=6, unit_id=1)
encoded = header.encode()
assert len(encoded) == 7
decoded = MbapHeader.decode(encoded + b"\x03\x00\x00\x00\x02")
assert decoded == header
def test_decode_requires_complete_frame():
with pytest.raises(MbapError):
MbapHeader.decode(b"\x00\x01\x00\x00\x00\x06\x01")
def test_protocol_id_rejected():
with pytest.raises(MbapError):
MbapHeader.decode(b"\x00\x01\x00\x01\x00\x06\x01\x03\x00\x00\x00\x02")
def test_build_frame():
frame = build_frame(7, 1, b"\x03\x00\x00\x00\x02")
assert frame[:2] == (7).to_bytes(2, "big")
assert frame[4:6] == (6).to_bytes(2, "big")
assert frame[6] == 1
def test_frame_reader_splits_stream():
reader = MbapFrameReader()
f1 = build_frame(1, 1, b"\x03\x00\x00\x00\x02")
f2 = build_frame(2, 1, b"\x01\x00\x00\x00\x08")
blob = f1 + f2
# feed an incomplete header first
reader.feed(blob[:5])
assert reader.pop_frame() is None
# feed through the end of frame 1 (12 bytes): it becomes complete
reader.feed(blob[5:12])
h1, p1 = reader.pop_frame()
assert h1.transaction_id == 1
assert p1 == b"\x03\x00\x00\x00\x02"
# frame 2 arrives in pieces
reader.feed(blob[12:15])
assert reader.pop_frame() is None
reader.feed(blob[15:])
h2, p2 = reader.pop_frame()
assert h2.transaction_id == 2
assert p2 == b"\x01\x00\x00\x00\x08"
assert reader.pop_frame() is None
def test_frame_reader_rejects_bad_protocol():
reader = MbapFrameReader()
reader.feed(b"\x00\x01\xDE\xAD\x00\x06\x01\x03\x00\x00\x00\x02")
import pytest
with pytest.raises(MbapError):
reader.pop_frame()
+188
View File
@@ -0,0 +1,188 @@
"""PDU codec tests: request decoding, response encoding, limits."""
from __future__ import annotations
import pytest
from wago_sim.pdu import (
COIL_OFF,
COIL_ON,
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
ExceptionResponse,
GetCommEventCountResponse,
IllegalDataValue,
MaskWriteRegisterResponse,
ReadRequest,
ReadResponse,
UnsupportedFunction,
WriteMultipleResponse,
WriteSingleCoilRequest,
WriteSingleCoilResponse,
WriteSingleRegisterRequest,
WriteSingleRegisterResponse,
decode_request,
FC_GET_COMM_EVENT_COUNT,
FC_MASK_WRITE_REGISTER,
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
MAX_READ_BITS,
MAX_READ_REGISTERS,
MAX_WRITE_BITS,
MAX_WRITE_REGISTERS,
)
def _req(fc: int, body: bytes) -> bytes:
return bytes([fc]) + body
class TestReadRequests:
@pytest.mark.parametrize("fc,max_count", [
(FC_READ_COILS, MAX_READ_BITS),
(FC_READ_DISCRETE_INPUTS, MAX_READ_BITS),
(FC_READ_HOLDING_REGISTERS, MAX_READ_REGISTERS),
(FC_READ_INPUT_REGISTERS, MAX_READ_REGISTERS),
])
def test_read_ok(self, fc, max_count):
pdu = _req(fc, (0x00).to_bytes(2, "big") + max_count.to_bytes(2, "big"))
req = decode_request(pdu)
assert isinstance(req, ReadRequest)
assert req.function_code == fc
assert req.count == max_count
def test_read_count_zero_rejected(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x00\x00"))
def test_read_count_over_limit_rejected(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x07\xD1")) # 2001
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_HOLDING_REGISTERS, b"\x00\x00\x00\x7E")) # 126
def test_read_body_length(self):
with pytest.raises(Exception):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x00"))
class TestWriteRequests:
def test_write_single_coil_on(self):
req = decode_request(_req(FC_WRITE_SINGLE_COIL, b"\x00\x05\xFF\x00"))
assert isinstance(req, WriteSingleCoilRequest)
assert req.address == 5
assert req.value is True
def test_write_single_coil_bad_value(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_SINGLE_COIL, b"\x00\x05\x12\x34"))
def test_write_single_register(self):
req = decode_request(_req(FC_WRITE_SINGLE_REGISTER, b"\x00\x0A\x12\x34"))
assert isinstance(req, WriteSingleRegisterRequest)
assert req.value == 0x1234
def test_write_multiple_coils(self):
# address 0, 10 coils, values 0b0000_0001_01
body = b"\x00\x00\x00\x0A\x02\x05\x01"
req = decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
assert req.function_code == FC_WRITE_MULTIPLE_COILS
assert len(req.values) == 10
assert req.values == (True, False, True, False, False, False, False,
False, True, False)
def test_write_multiple_coils_count_mismatch(self):
body = b"\x00\x00\x00\x0A\x03\x05\x01\x00" # announces 3 bytes for 10 bits
with pytest.raises(Exception):
decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
def test_write_multiple_coils_over_limit(self):
body = b"\x00\x00\x03\x21\x65" + b"\xFF" * 0x65
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
def test_write_multiple_registers(self):
body = b"\x00\x00\x00\x02\x04\x12\x34\x56\x78"
req = decode_request(_req(FC_WRITE_MULTIPLE_REGISTERS, body))
assert req.values == (0x1234, 0x5678)
def test_write_multiple_registers_over_limit(self):
count = MAX_WRITE_REGISTERS + 1
body = count.to_bytes(2, "big")[:2] + (2 * count).to_bytes(1, "big") + b"\x00" * (2 * count)
# count field is 2 bytes; re-encode properly
body = b"\x00\x00" + count.to_bytes(2, "big") + (2 * count).to_bytes(1, "big") + b"\x00" * (2 * count)
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_MULTIPLE_REGISTERS, body))
def test_mask_write(self):
req = decode_request(_req(FC_MASK_WRITE_REGISTER, b"\x00\x0A\x00\xF2\x00\x25"))
assert req.address == 0x0A
assert req.and_mask == 0x00F2
assert req.or_mask == 0x0025
def test_read_write_multiple(self):
body = (b"\x00\x00\x00\x02" # read addr 0, count 2
b"\x10\x00\x00\x02" # write addr 0x1000, count 2
b"\x04\xAA\xBB\xCC\xDD") # byte count (1 byte) + 4 data bytes
req = decode_request(_req(FC_READ_WRITE_MULTIPLE_REGISTERS, body))
assert req.read_count == 2
assert req.write_address == 0x1000
assert req.values == (0xAABB, 0xCCDD)
class TestUnsupported:
def test_fc7_rejected(self):
with pytest.raises(UnsupportedFunction) as ei:
decode_request(_req(0x07, b"\x00\x00"))
assert ei.value.function_code == 0x07
def test_fc8_rejected(self):
with pytest.raises(UnsupportedFunction):
decode_request(_req(0x08, b"\x00\x00\x00\x00"))
class TestResponses:
def test_read_bits_encoding(self):
resp = ReadResponse(FC_READ_COILS)
resp.bits = [True, False, True] + [False] * 5
assert resp.encode() == b"\x01\x01\x05"
def test_read_registers_encoding(self):
resp = ReadResponse(FC_READ_HOLDING_REGISTERS)
resp.registers = [0x1234, 0x5678]
assert resp.encode() == b"\x03\x04\x12\x34\x56\x78"
def test_write_single_coil_response(self):
resp = WriteSingleCoilResponse(5, True)
assert resp.encode() == b"\x05\x00\x05\xFF\x00"
def test_write_single_register_response(self):
resp = WriteSingleRegisterResponse(2, 0xBEEF)
assert resp.encode() == b"\x06\x00\x02\xBE\xEF"
def test_write_multiple_response(self):
resp = WriteMultipleResponse(FC_WRITE_MULTIPLE_REGISTERS, 0, 4)
assert resp.encode() == b"\x10\x00\x00\x00\x04"
def test_mask_write_response(self):
resp = MaskWriteRegisterResponse(4, 0x00F2, 0x0025)
assert resp.encode() == b"\x16\x00\x04\x00\xF2\x00\x25"
def test_event_count_response(self):
resp = GetCommEventCountResponse(0, 42)
assert resp.encode() == b"\x0B\x00\x00\x00\x2A"
def test_exception_response(self):
resp = ExceptionResponse(FC_READ_COILS, EXC_ILLEGAL_DATA_ADDRESS)
assert resp.encode() == b"\x81\x02"
def test_event_count_request(self):
req = decode_request(_req(FC_GET_COMM_EVENT_COUNT, b""))
assert req.function_code == FC_GET_COMM_EVENT_COUNT
+223
View File
@@ -0,0 +1,223 @@
"""Register bank, statistics, and watchdog tests."""
from __future__ import annotations
import pytest
from wago_sim.registers import ConfigRegisterBank
from wago_sim.stats import ModbusStatistics
from wago_sim.watchdog import (
ModbusWatchdog,
RegisterWriteRefused,
WD_CLOSE_ON_TIMEOUT,
WD_MASK_1_16,
WD_SIMPLE_STOP,
WD_STATUS,
WD_STATUS_ACTIVE,
WD_STATUS_EXPIRED,
WD_STATUS_INACTIVE,
WD_TIME,
WD_TRIGGER,
WD_TYPE,
)
class TestConstants:
"""Acceptance criterion 1: constant registers 0x2000-0x2008."""
@pytest.mark.parametrize("address,expected", [
(0x2000, 0x0000),
(0x2001, 0xFFFF),
(0x2002, 0x1234),
(0x2003, 0xAAAA),
(0x2004, 0x5555),
(0x2005, 0x7FFF),
(0x2006, 0x8000),
(0x2007, 0x3FFF),
(0x2008, 0x4000),
])
def test_constant_block(self, app, address, expected):
assert app.registers.read_config(address, 1) == [expected]
def test_constant_block_bulk(self, app):
words = app.registers.read_config(0x2000, 9)
assert words == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555,
0x7FFF, 0x8000, 0x3FFF, 0x4000]
class TestFirmwareInfo:
def test_series_code_is_750(self, app):
assert app.registers.read_config(0x2011, 1) == [0x02EE]
def test_description_words(self, app):
words = app.registers.read_config(0x2020, 16)
text = "".join(chr((w >> 8) & 0xFF) + chr(w & 0xFF) for w in words)
assert text.startswith("WAGO 750-362 Simulator")
def test_image_sizes(self, app):
# pump node: 2 AI words + 2 AO words, 24 DI, 24 DO
assert app.registers.read_config(0x1022, 1) == [2 * 16] # analog out bits
assert app.registers.read_config(0x1023, 1) == [2 * 16] # analog in bits
assert app.registers.read_config(0x1024, 1) == [24] # digital out bits
assert app.registers.read_config(0x1025, 1) == [24] # digital in bits
class TestStatistics:
def test_initial_zero(self, app):
words = app.registers.read_config(0x1029, 9)
assert words == [0] * 9
def test_counter_readback(self, app):
app.stats.bad_address = 3
app.stats.mod_tcp_message_counter = 42
words = app.registers.read_config(0x1029, 9)
assert words[4] == 3
assert words[8] == 42
def test_reset_via_magic_write(self, app):
app.stats.bad_address = 3
app.stats.mod_tcp_message_counter = 42
app.registers.write_config(0x1029, [0xAA55])
words = app.registers.read_config(0x1029, 9)
assert words == [0] * 9
def test_reset_key_55aa(self):
stats = ModbusStatistics()
stats.bad_length = 1
assert stats.handle_reset_write(0x55AA) is True
assert stats.bad_length == 0
assert stats.handle_reset_write(0x1234) is False
class TestWatchdog:
def _make(self):
expired = []
wd = ModbusWatchdog(on_expire=lambda: expired.append(1))
return wd, expired
def test_defaults(self):
wd, _ = self._make()
assert wd.read(WD_TIME) == 0x0064
assert wd.read(WD_MASK_1_16) == 0xFFFF
assert wd.read(WD_STATUS) == WD_STATUS_INACTIVE
def test_cannot_change_time_while_active(self):
wd, _ = self._make()
wd.write(WD_TRIGGER, 5) # start via trigger
with pytest.raises(RegisterWriteRefused):
wd.write(WD_TIME, 20)
def test_trigger_starts_and_expires(self):
wd, expired = self._make()
wd.write(WD_TIME, 5) # 500 ms
wd.write(WD_TRIGGER, 1)
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
for _ in range(5):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
assert expired == [1]
assert wd.process_data_blocked is True # Standard mode
def test_trigger_alternating_kicks(self):
wd, expired = self._make()
wd.write(WD_TIME, 5)
wd.write(WD_TRIGGER, 1)
for i in range(10):
wd.write(WD_TRIGGER, i % 2) # alternating 0/1 values kick
for _ in range(3):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert expired == []
def test_trigger_same_value_does_not_kick(self):
wd, expired = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
def test_function_code_mask_kick(self):
wd, expired = self._make()
wd.write(WD_TIME, 5)
wd.write(WD_MASK_1_16, 1 << 4) # only FC5 monitored
wd.write(WD_TRIGGER, 1)
wd.on_function_code(3) # not masked -> no kick
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
wd.on_function_code(5) # masked -> kick
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
wd.on_function_code(5)
for _ in range(5):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
assert expired == [1]
def test_simple_stop(self):
wd, expired = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.write(WD_SIMPLE_STOP, 0xAA55)
assert wd.read(WD_STATUS) == WD_STATUS_INACTIVE
for _ in range(5):
wd.tick()
assert expired == []
def test_restart_after_expiry(self):
wd, _ = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.tick(); wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
wd.write(0x1007, 1) # restart
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert wd.process_data_blocked is False
def test_alternative_mode_restart_on_read(self):
wd, _ = self._make()
wd.write(WD_TYPE, 1) # Alternative
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.tick(); wd.tick()
# first read returns "expired" and restarts the watchdog
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
# subsequent read shows "active" again
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert wd.process_data_blocked is False
def test_close_connections_flag(self):
wd, _ = self._make()
assert wd.close_connections_on_timeout is False
wd.write(WD_CLOSE_ON_TIMEOUT, 1)
assert wd.close_connections_on_timeout is True
class TestMiscRegisters:
def test_mac_id(self, app):
words = app.registers.read_config(0x1031, 3)
# register exposes the first 3 MAC bytes, one per word (high byte)
assert words == [0x00 << 8, 0x30 << 8, 0xDE << 8]
def test_response_delay_writable(self, app):
app.registers.write_config(0x1037, [25])
assert app.registers.response_delay_ms == 25
def test_idle_timeout_writable(self, app):
app.registers.write_config(0x1030, [10])
assert app.registers.idle_timeout_ms == 1000
def test_readonly_register_rejects_write(self, app):
from wago_sim.watchdog import RegisterWriteRefused
with pytest.raises(RegisterWriteRefused):
app.registers.write_config(0x2002, [0x1111])
def test_led_error_injection(self, app):
app.registers.set_led_error(0x12, 0x34)
assert app.registers.read_config(0x1020, 2) == [0x12, 0x34]
def test_module_diagnosis_injection(self, app):
app.registers.set_module_diagnosis(module=2, channel=1, diagnosis=0b100)
assert app.registers.read_config(0x1050, 3) == [2, 1, 0b100]
+317
View File
@@ -0,0 +1,317 @@
"""Scenario engine tests: aliases, macros, pulses, ramps, assertions."""
from __future__ import annotations
import pytest
from wago_sim.scenario.engine import ScenarioEngine, ScenarioError, Verdict
def _scenario(**overrides) -> dict:
base = {
"name": "test_scenario",
"inputs": {
"start_button": {"type": "di", "channel": 1},
"level_sensor": {"type": "ai", "channel": 1, "scale": 1.0},
},
"outputs": {
"pump_relay": {"type": "do", "channel": 1},
},
"sequence": [],
}
base.update(overrides)
return base
class _Pump:
"""Tiny plant model inside the test: emulates PLC behavior.
PLC logic: relay follows the start button (combinatorial copy of
the input image into the output image, as a real master would do
via Modbus writes).
"""
def __init__(self, engine: ScenarioEngine) -> None:
self.engine = engine
# loc.bit == global 0-based digital channel index == Modbus address
self._bit = engine.alias_location("pump_relay").bit
def apply(self) -> None:
ds = self.engine.datastore
on = bool(self.engine.alias_value("start_button"))
# emulate a Modbus master writing the coil at the global address
result = ds.write_bits(self._bit, [on])
assert not isinstance(result, Exception), result
def _run(engine: ScenarioEngine, plant=None, max_ticks=10000):
engine.start()
result = None
for _ in range(max_ticks):
if plant is not None:
plant.apply()
result = engine.pump()
if result is not None:
return result
raise AssertionError("scenario did not finish")
class TestAliases:
def test_di_alias_maps_channel(self, node, datastore):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("start_button", True)
assert datastore.read_bits(0, 1) == [True]
def test_ai_alias_applies_scale(self, node):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("level_sensor", 100)
loc = node.channel("ai", 1)
assert node.datastore.get_input_word(loc.word) == 100
assert engine.alias_value("level_sensor") == 100
def test_invalid_channel_rejected(self, node):
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "channel": 999}
with pytest.raises(IndexError):
ScenarioEngine(node, scenario)
def test_output_alias_read(self, node, datastore):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("pump_relay").bit
assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("pump_relay") is True
class TestModuleAddressing:
"""Module type + module number + channel number addressing (DI0.1).
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
(indices 0..5). Module addressing uses 0-based module positions and
0-based channel numbers on the module.
"""
def test_module_addressing_di(self, node, datastore):
# DI0.1: module slot 0 (DI_8), channel 1 (0-based) = 2nd DI
scenario = _scenario()
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
engine = ScenarioEngine(node, scenario)
engine.set_alias("second_di", True)
# global bit index 1 -> Modbus bit address 1
assert datastore.read_bits(1, 1) == [True]
assert engine.alias_value("second_di") is True
def test_module_addressing_across_modules(self, node, datastore):
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
# (8 channels on module 0 come first)
scenario = _scenario()
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
engine = ScenarioEngine(node, scenario)
engine.set_alias("di16_first", True)
assert datastore.read_bits(8, 1) == [True]
def test_module_addressing_do(self, node, datastore):
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
scenario = _scenario()
scenario["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("third_do").bit
assert bit == 2
assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("third_do") is True
def test_module_addressing_ai_ao(self, node, datastore):
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
scenario = _scenario()
scenario["inputs"]["ai2"] = {"type": "ai", "module": 4, "channel": 1, "scale": 1.0}
engine = ScenarioEngine(node, scenario)
engine.set_alias("ai2", 0x1234)
loc = node.channel("ai", 2) # global 2nd AI
assert datastore.get_input_word(loc.word) == 0x1234
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
engine2 = ScenarioEngine(node, scenario)
bit_word = node.channel("ao", 2).word
assert datastore.write_words(bit_word, [0xBEEF]) is None
assert engine2.alias_value("ao2") == 0xBEEF
def test_module_equivalent_to_pool_addressing(self, node):
"""DI0.0 (module addressing) == di channel 1 (pool addressing)."""
scenario = _scenario()
scenario["inputs"]["m"] = {"type": "di", "module": 0, "channel": 0}
scenario["inputs"]["p"] = {"type": "di", "channel": 1}
engine = ScenarioEngine(node, scenario)
engine.set_alias("m", True)
assert engine.alias_value("p") is True
def test_module_out_of_range_rejected(self, node):
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
with pytest.raises(IndexError, match="module 99"):
ScenarioEngine(node, scenario)
def test_channel_out_of_range_rejected(self, node):
# module 0 is a DI_8 -> channels 0-7 only
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
with pytest.raises(IndexError, match="channel 8"):
ScenarioEngine(node, scenario)
def test_kind_mismatch_rejected(self, node):
# module 0 is DI-only -> DO addressing must fail
scenario = _scenario()
scenario["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario)
def test_analog_kind_on_digital_module_rejected(self, node):
# module 0 is DI-only -> AI addressing must fail
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario)
class TestSequence:
def test_set_and_assert_pass(self, node):
scenario = _scenario(sequence=[
{"set": {"start_button": True}},
{"assert": {"label": "relay on", "conditions": {"start_button": "ON"}, "within": 0}},
])
# plant copies the input directly to the relay
engine = ScenarioEngine(node, scenario)
plant = _Pump(engine)
result = _run(engine, plant)
assert result.verdict is Verdict.PASS
def test_assert_timeout_fails(self, node):
scenario = _scenario(sequence=[
{"set": {"start_button": True}},
{"assert": {"label": "relay on", "conditions": {"pump_relay": "ON"}, "within": 100}},
])
engine = ScenarioEngine(node, scenario)
# no plant -> relay never turns on
result = _run(engine)
assert result.verdict is Verdict.FAIL
assert result.steps[-1].verdict is Verdict.FAIL
def test_wait_step(self, node):
scenario = _scenario(sequence=[
{"wait": 50},
{"assert": {"label": "done waiting", "conditions": {"start_button": "OFF"}, "within": 0}},
])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
assert result.duration_ms >= 50
def test_pulse_macro(self, node):
scenario = _scenario(
macros={
"press": [
{"set": {"start_button": True}},
{"wait": 30},
{"set": {"start_button": False}},
]
},
sequence=[
{"macro": "press"},
{"wait": 100},
{"assert": {"label": "released", "conditions": {"start_button": "OFF"}, "within": 0}},
],
)
engine = ScenarioEngine(node, scenario)
seen_on = False
engine.start()
result = None
for _ in range(500):
result = engine.pump()
if engine.alias_value("start_button"):
seen_on = True
if result is not None:
break
assert result.verdict is Verdict.PASS
assert seen_on
assert engine.alias_value("start_button") is False
def test_unknown_step_rejected(self, node):
scenario = _scenario(sequence=[{"frobnicate": {}}])
engine = ScenarioEngine(node, scenario)
engine.start()
with pytest.raises(ScenarioError):
engine.pump()
def test_report_pass_finishes(self, node):
scenario = _scenario(sequence=[{"report": "PASS"}])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
def test_report_fail(self, node):
scenario = _scenario(sequence=[{"report": "FAIL"}])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.FAIL
class TestRamps:
def test_ramp_reaches_target(self, node):
scenario = _scenario(sequence=[
{"ramp": {"name": "level_sensor", "to": 1000, "over": 100}},
{"wait": 120},
{"assert": {"label": "level", "conditions": {"level_sensor": ">= 999"}, "within": 0}},
])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
assert engine.alias_value("level_sensor") == 1000
def test_ramp_progresses_linearly(self, node):
scenario = _scenario(sequence=[
{"ramp": {"name": "level_sensor", "to": 1000, "over": 1000}},
{"wait": 100000},
])
engine = ScenarioEngine(node, scenario)
engine.start()
samples = []
for _ in range(6):
engine.pump(100)
samples.append(engine.alias_value("level_sensor"))
# ramp starts on the first tick; +100 per 100 ms thereafter
assert samples == [0, 100, 200, 300, 400, 500]
class TestEdgeDetection:
def test_rising_edge_latched(self, node, datastore):
# a pending wait keeps the scenario alive while we flip the output
scenario = _scenario(sequence=[{"wait": 10000}])
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("pump_relay").bit
engine.pump()
assert datastore.write_bits(bit, [True]) is None
engine.pump()
assert engine.consume_rising_edge("pump_relay") is True
assert engine.consume_rising_edge("pump_relay") is False
class TestConditionMatcher:
def test_operators(self, node):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("level_sensor", 500)
m = engine._matcher
resolve = engine._resolve_alias
assert m.evaluate({"level_sensor": 500})
assert m.evaluate({"level_sensor": ">= 499"})
assert not m.evaluate({"level_sensor": ">= 501"})
assert m.evaluate({"level_sensor": "< 501"})
assert m.evaluate({"level_sensor": "!= 499"})
assert m.evaluate({"level_sensor": "== 500"})
assert not m.evaluate({"level_sensor": "> 500"})
# boolean forms
assert m.evaluate({"start_button": "OFF"})
+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()