🎉 Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user