🎉 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
+7
View File
@@ -0,0 +1,7 @@
"""WAGO Modbus TCP Simulator.
Simulates a WAGO 750-362 Modbus TCP fieldbus coupler for autonomous
FAT (Factory Acceptance Test) testing of PLC programs.
"""
__version__ = "0.1.0"
+107
View File
@@ -0,0 +1,107 @@
"""Application assembly: wiring datastore, registers, watchdog, node, server."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .datastore import WagoDatastore
from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank
from .server import WagoSimServer
from .stats import ModbusStatistics
from .watchdog import ModbusWatchdog
@dataclass
class SimulatorApp:
"""Fully wired simulator instance for one virtual node."""
datastore: WagoDatastore
node: VirtualNode
stats: ModbusStatistics
watchdog: ModbusWatchdog
registers: ConfigRegisterBank
server: WagoSimServer
@property
def modules(self) -> list[ModuleType]:
return self.node.modules
def build_app(
*,
modules: list[ModuleType],
host: str = "0.0.0.0",
port: int = 502,
mac_id: tuple[int, int, int, int, int, int] = (0x00, 0x30, 0xDE, 0x11, 0x22, 0x33),
firmware_description: str = "WAGO 750-362 Simulator",
) -> SimulatorApp:
"""Assemble all simulator components for a node configuration."""
stats = ModbusStatistics()
server_ref: dict[str, WagoSimServer] = {}
def on_expire() -> None:
datastore.clear_outputs()
stats.slave_device_failure += 1
def on_connections_close() -> None:
"""Watchdog 0x1009=1: close TCP connections (from sync context).
Called by the watchdog (possibly from its tick task). Schedules
the async close on the running event loop when one exists.
"""
server = server_ref.get("server")
if server is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(server.close_all_connections())
watchdog = ModbusWatchdog(
on_expire=on_expire,
on_connections_close=on_connections_close,
)
datastore = WagoDatastore(config_bank=None) # type: ignore[arg-type]
registers = ConfigRegisterBank(
statistics=stats,
watchdog=watchdog,
tcp_connection_counter=lambda: server.connection_count,
mac_id=mac_id,
firmware_description=firmware_description,
on_software_reset=lambda: datastore.clear_outputs(),
on_kbus_reset=lambda: None,
)
datastore._config = registers # late binding to break the cycle
node = VirtualNode(datastore, modules)
registers.set_process_image_bits(
analog_in=node.analog_in_bits,
analog_out=node.analog_out_bits,
digital_in=node.digital_in_bits,
digital_out=node.digital_out_bits,
)
registers.set_module_description(node.description())
server = WagoSimServer(
datastore,
stats,
watchdog,
host=host,
port=port,
response_delay_provider=lambda: registers.response_delay_ms,
idle_timeout_provider=lambda: registers.idle_timeout_ms,
)
server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp(
datastore=datastore,
node=node,
stats=stats,
watchdog=watchdog,
registers=registers,
server=server,
)
+125
View File
@@ -0,0 +1,125 @@
"""Command-line interface: run a node configuration and a scenario.
Examples::
wago-sim serve --node config/pump_node.yaml --port 502
wago-sim run --node config/pump_node.yaml --scenario scenarios/pump_station_fat.yaml
wago-sim validate --scenario scenarios/pump_station_fat.yaml
"""
from __future__ import annotations
import argparse
import asyncio
import inspect
import logging
import sys
from pathlib import Path
from .app import build_app
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
from .scenario.engine import ScenarioEngine
from .scenario.report import result_summary, write_csv, write_jsonl
def _build_logger(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
async def _serve(args: argparse.Namespace) -> int:
node_config = load_yaml(args.node)
modules = load_node_modules(node_config)
app = build_app(modules=modules, host=args.host, port=args.port)
await app.server.start()
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
f"on {args.host}:{args.port} (Ctrl+C to stop)")
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await app.server.stop()
return 0
def _run_scenario(args: argparse.Namespace) -> int:
node_config = load_yaml(args.node)
modules = load_node_modules(node_config)
scenario = load_yaml(args.scenario)
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 2
app = build_app(modules=modules)
engine = ScenarioEngine(app.node, scenario)
engine.start()
result = None
max_ticks = 2_000_000
ticks = 0
while result is None and ticks < max_ticks:
result = engine.pump()
ticks += 1
if result is None:
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
return 3
print(result_summary(result))
if args.report_jsonl:
write_jsonl(result, args.report_jsonl)
if args.report_csv:
write_csv(result, args.report_csv)
return 0 if result.passed else 1
def _validate(args: argparse.Namespace) -> int:
scenario = load_yaml(args.scenario)
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:")
for p in problems:
print(f" - {p}")
return 2
print("OK")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="wago-sim",
description="WAGO 750-362 Modbus TCP simulator for PLC FAT testing",
)
parser.add_argument("-v", "--verbose", action="store_true")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="run the Modbus TCP server")
serve.add_argument("--node", required=True, help="node config YAML")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=502)
serve.set_defaults(func=_serve)
runp = sub.add_parser("run", help="run a scenario headless (no server)")
runp.add_argument("--node", required=True, help="node config YAML")
runp.add_argument("--scenario", required=True, help="scenario YAML")
runp.add_argument("--report-jsonl", default=None)
runp.add_argument("--report-csv", default=None)
runp.set_defaults(func=_run_scenario)
val = sub.add_parser("validate", help="validate a scenario file")
val.add_argument("--scenario", required=True)
val.set_defaults(func=_validate)
args = parser.parse_args(argv)
_build_logger(args.verbose)
if inspect.iscoroutinefunction(args.func):
return asyncio.run(args.func(args))
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
+407
View File
@@ -0,0 +1,407 @@
"""WAGO 750-362 data model: process images and address-map dispatch.
Implements the register mapping tables of the 750-362 manual §11.2.4:
Word area:
0x0000-0x00FF input image area 1 (first 256 words) [R]
0x0100-0x01FF illegal [R/W]
0x0200-0x02FF output image area 1 readback (+0x0200) [R, mirrored W]
0x0300-0x0FFF illegal [R/W]
0x1000-0x2FFF configuration registers [R/W]
0x3000-0x5FFF illegal [R/W]
0x6000-0x62FB input image area 2 (words 256-1020) [R]
0x62FC-0x6FFF illegal [R/W]
0x7000-0x72FB output image area 2 readback (+0x1000) [R, mirrored W]
0x72FC-0xFFFF illegal [R/W]
Word writes (FC6/FC16) land in the output image:
0x0000-0x00FF output image area 1
0x0200-0x02FF output image area 1 (mirror accepted)
0x6000-0x62FB output image area 2
0x7000-0x72FB output image area 2 (mirror accepted)
Bit area (FC1/2/5/15), addresses are *digital channel numbers*:
0x0000-0x01FF digital inputs 1-512 [R]
0x0200-0x03FF digital outputs 1-512 [R/W]
0x0800-0x85F7 digital inputs 513-2039 [R]
0x9000-0x95F7 digital outputs 513-2039 [R/W]
everything else illegal
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
from .pdu import (
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
ExceptionResponse,
MaskWriteRegisterRequest,
ReadRequest,
ReadWriteMultipleRegistersRequest,
WriteMultipleCoilsRequest,
WriteMultipleRegistersRequest,
WriteSingleCoilRequest,
WriteSingleRegisterRequest,
)
# --- Word area geometry -----------------------------------------------------
AREA1_INPUT_WORDS = 256 # 0x0000-0x00FF
AREA2_INPUT_WORDS = 764 # 0x6000-0x62FB (%IW256-%IW1020)
AREA1_OUTPUT_WORDS = 256 # 0x0200-0x02FF mirror of %QW0-255
AREA2_OUTPUT_WORDS = 764 # 0x7000-0x72FB mirror of %QW256-1020
WORD_IN1_BASE = 0x0000
WORD_IN2_BASE = 0x6000
WORD_OUT1_BASE = 0x0000
WORD_OUT1_MIRROR_BASE = 0x0200
WORD_OUT2_BASE = 0x6000
WORD_OUT2_MIRROR_BASE = 0x7000
WORD_CONFIG_BASE = 0x1000
WORD_CONFIG_END = 0x2FFF
# --- Bit area geometry ------------------------------------------------------
BIT_IN1_BASE = 0x0000 # 0x0000-0x01FF DI 1-512
BIT_IN1_COUNT = 512
BIT_OUT1_BASE = 0x0200 # 0x0200-0x03FF DO 1-512
BIT_OUT1_COUNT = 512
BIT_IN2_BASE = 0x8000 # 0x8000-0x85F7 DI 513-2039
BIT_IN2_COUNT = 1527
BIT_OUT2_BASE = 0x9000 # 0x9000-0x95F7 DO 513-2039
BIT_OUT2_COUNT = 1527
DIAG_READ_LIMIT = 125
DIAG_WRITE_LIMIT = 100
COIL_READ_LIMIT = 2000
COIL_WRITE_LIMIT = 800
class AddressError(Exception):
"""Modbus address outside any mapped area (exception 0x02)."""
def __init__(self, address: int) -> None:
self.address = address
super().__init__(f"illegal address: {address:#06x}")
class ConfigRegisterError(Exception):
"""A configuration-register operation was rejected."""
class ConfigRegisters(Protocol):
"""Interface to the configuration-register bank (0x1000-0x2FFF)."""
def read_config(self, address: int, count: int) -> list[int]: ...
def write_config(self, address: int, values: list[int]) -> None: ...
class WagoDatastore:
"""Holds both process images and dispatches word/bit accesses.
The process images are plain word arrays; digital data is packed into
words using the Intel format (manual §7.3.3): channel 1 = bit 0 of
the low byte D0.
"""
def __init__(self, *, config_bank: ConfigRegisters) -> None:
self._config = config_bank
self._in_words = [0] * (AREA1_INPUT_WORDS + AREA2_INPUT_WORDS)
self._out_words = [0] * (AREA1_OUTPUT_WORDS + AREA2_OUTPUT_WORDS)
self._listeners: list[Callable[[], None]] = []
# Digital channels are byte-packed AFTER the complex (analog)
# modules in the image (manual §7.3.2.1): bit address g (channel
# number - 1) lives at word offset + g//16, bit g%16.
self._digital_in_word_offset = 0
self._digital_out_word_offset = 0
def set_digital_word_offsets(self, in_words: int, out_words: int) -> None:
"""Tell the datastore where the byte-packed digital area starts.
Called by the VirtualNode after layout: `in_words`/`out_words`
are the total analog (word-oriented) module word counts.
"""
self._digital_in_word_offset = in_words
self._digital_out_word_offset = out_words
# -- subscription (scenario engine observes output writes) --------------
def add_listener(self, fn: Callable[[], None]) -> None:
self._listeners.append(fn)
def _notify(self) -> None:
for fn in self._listeners:
fn()
# -- process image access (scenario engine / node side) ------------------
@property
def input_words(self) -> list[int]:
"""Simulated inputs (PII), indices 0..1019 (word 0..255 area 1)."""
return self._in_words
@property
def output_words(self) -> list[int]:
"""PLC-written outputs (PIO), indices 0..1019."""
return self._out_words
def set_input_word(self, index: int, value: int) -> None:
self._validate_index(index, len(self._in_words))
self._in_words[index] = value & 0xFFFF
def get_input_word(self, index: int) -> int:
self._validate_index(index, len(self._in_words))
return self._in_words[index]
def set_input_bit(self, index: int, bit: int, value: bool) -> None:
self._validate_index(index, len(self._in_words))
if value:
self._in_words[index] |= 1 << bit
else:
self._in_words[index] &= ~(1 << bit) & 0xFFFF
def get_input_bit(self, index: int, bit: int) -> bool:
return bool(self._in_words[index] & (1 << bit))
def get_output_bit(self, index: int, bit: int) -> bool:
return bool(self._out_words[index] & (1 << bit))
def get_output_word(self, index: int) -> int:
return self._out_words[index]
def clear_outputs(self) -> None:
"""Watchdog timeout reaction: analog->0, digital->OFF."""
self._out_words = [0] * len(self._out_words)
self._notify()
# -- word area dispatch ---------------------------------------------------
def read_words(self, address: int, count: int) -> list[int] | ExceptionResponse:
if not 1 <= count <= DIAG_READ_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
return self._read_words_impl(address, count)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _read_words_impl(self, address: int, count: int) -> list[int]:
end = address + count
if WORD_IN1_BASE <= address and end <= WORD_IN1_BASE + AREA1_INPUT_WORDS:
return self._in_words[address - WORD_IN1_BASE:end - WORD_IN1_BASE]
if WORD_OUT1_MIRROR_BASE <= address and end <= WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS:
off = address - WORD_OUT1_MIRROR_BASE
return self._out_words[off:off + count]
if WORD_CONFIG_BASE <= address and end <= WORD_CONFIG_END + 1:
return self._config.read_config(address, count)
if WORD_IN2_BASE <= address and end <= WORD_IN2_BASE + AREA2_INPUT_WORDS:
off = address - WORD_IN2_BASE + AREA1_INPUT_WORDS
return self._in_words[off:off + count]
if WORD_OUT2_MIRROR_BASE <= address and end <= WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
return self._out_words[off:off + count]
raise AddressError(address)
def write_words(self, address: int, values: list[int]) -> None | ExceptionResponse:
if not 1 <= len(values) <= DIAG_WRITE_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
self._write_words_impl(address, values)
return None
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _write_words_impl(self, address: int, values: list[int]) -> None:
count = len(values)
end = address + count
if WORD_OUT1_BASE <= address and end <= WORD_OUT1_BASE + AREA1_OUTPUT_WORDS:
self._out_words[address - WORD_OUT1_BASE:end - WORD_OUT1_BASE] = [
v & 0xFFFF for v in values
]
self._notify()
return
if WORD_OUT1_MIRROR_BASE <= address and end <= WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS:
off = address - WORD_OUT1_MIRROR_BASE
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
if WORD_CONFIG_BASE <= address and end <= WORD_CONFIG_END + 1:
self._config.write_config(address, values)
return
if WORD_OUT2_BASE <= address and end <= WORD_OUT2_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_BASE + AREA1_OUTPUT_WORDS
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
if WORD_OUT2_MIRROR_BASE <= address and end <= WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
raise AddressError(address)
def mask_write_word(self, address: int, and_mask: int, or_mask: int) -> int | ExceptionResponse:
"""FC22: result = (current AND and_mask) OR (or_mask AND NOT and_mask).
The 'current' value is the register under the WRITE mapping
(FC6/FC16, Table 120): address 0x0000-0x00FF is %QW0-255.
"""
try:
current = self._read_write_target(address)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
value = (current & and_mask) | (or_mask & ~and_mask & 0xFFFF)
result = self.write_words(address, [value])
if isinstance(result, ExceptionResponse):
return result
return value
def _read_write_target(self, address: int) -> int:
"""Current value at `address` under the write (FC6/FC16) mapping."""
if WORD_OUT1_BASE <= address < WORD_OUT1_BASE + AREA1_OUTPUT_WORDS:
return self._out_words[address - WORD_OUT1_BASE]
if (WORD_OUT1_MIRROR_BASE <= address
< WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS):
return self._out_words[address - WORD_OUT1_MIRROR_BASE]
if WORD_CONFIG_BASE <= address <= WORD_CONFIG_END:
return self._config.read_config(address, 1)[0]
if WORD_OUT2_BASE <= address < WORD_OUT2_BASE + AREA2_OUTPUT_WORDS:
return self._out_words[address - WORD_OUT2_BASE + AREA1_OUTPUT_WORDS]
if (WORD_OUT2_MIRROR_BASE <= address
< WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS):
return self._out_words[
address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
]
raise AddressError(address)
# -- bit area dispatch -----------------------------------------------------
def read_bits(self, address: int, count: int) -> list[bool] | ExceptionResponse:
"""FC1/FC2 bit reads: the ADDRESS selects the area (Table 121).
0x0000-0x01FF -> inputs (channels 1-512),
0x0200-0x03FF -> outputs (read-back mirror),
0x8000-0x85F7 -> inputs 513-2039,
0x9000-0x95F7 -> outputs 513-2039.
"""
if not 1 <= count <= COIL_READ_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
return self._read_bits_impl(address, count)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _read_bits_impl(self, address: int, count: int) -> list[bool]:
direction, g0, area_bits = self._bit_read_area(address)
if g0 + count > area_bits:
raise AddressError(address) # spans into an illegal area
words, offset = self._direction_arrays(direction)
bits: list[bool] = []
for i in range(count):
g = g0 + i
word = offset + g // 16
if word >= len(words):
raise AddressError(address) # beyond configured image
bits.append(bool(words[word] & (1 << (g % 16))))
return bits
def write_bits(self, address: int, values: list[bool]) -> None | ExceptionResponse:
if not 1 <= len(values) <= COIL_WRITE_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
self._write_bits_impl(address, values)
return None
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _write_bits_impl(self, address: int, values: list[bool]) -> None:
direction, g0, area_bits = self._bit_write_area(address)
if g0 + len(values) > area_bits:
raise AddressError(address) # spans into an illegal area
words, offset = self._direction_arrays(direction)
for i, value in enumerate(values):
g = g0 + i
word = offset + g // 16
if word >= len(words):
raise AddressError(address) # beyond configured image
if value:
words[word] |= 1 << (g % 16)
else:
words[word] &= ~(1 << (g % 16)) & 0xFFFF
self._notify()
def _direction_arrays(self, direction: str) -> tuple[list[int], int]:
if direction == "in":
return self._in_words, self._digital_in_word_offset
return self._out_words, self._digital_out_word_offset
def _bit_read_area(self, address: int) -> tuple[str, int, int]:
"""Map a bit-read address to (direction, global bit index, area size).
The global bit index is the digital channel number - 1; storage
word = digital offset + g//16 (byte-packing, §7.3.2.1).
"""
if BIT_IN1_BASE <= address < BIT_IN1_BASE + BIT_IN1_COUNT:
return "in", address, BIT_IN1_COUNT
if BIT_OUT1_BASE <= address < BIT_OUT1_BASE + BIT_OUT1_COUNT:
return "out", address - BIT_OUT1_BASE, BIT_OUT1_COUNT
if BIT_IN2_BASE <= address < BIT_IN2_BASE + BIT_IN2_COUNT:
return "in", BIT_IN1_COUNT + (address - BIT_IN2_BASE), BIT_IN2_COUNT
if BIT_OUT2_BASE <= address < BIT_OUT2_BASE + BIT_OUT2_COUNT:
return "out", BIT_OUT1_COUNT + (address - BIT_OUT2_BASE), BIT_OUT2_COUNT
raise AddressError(address)
def _bit_write_area(self, address: int) -> tuple[str, int, int]:
"""Map a bit-write address to (direction, global bit index, area size).
Table 122: ALL bit writes target OUTPUT areas —
0x0000-0x01FF and 0x0200-0x03FF -> outputs 1-512,
0x8000-0x85F7 and 0x9000-0x95F7 -> outputs 513-2039.
"""
if address < BIT_IN2_BASE:
if address < BIT_IN1_COUNT:
return "out", address, BIT_IN1_COUNT
if BIT_OUT1_BASE <= address < BIT_OUT1_BASE + BIT_OUT1_COUNT:
return "out", address - BIT_OUT1_BASE, BIT_OUT1_COUNT
raise AddressError(address)
if BIT_IN2_BASE <= address < BIT_IN2_BASE + BIT_IN2_COUNT:
return "out", BIT_IN1_COUNT + (address - BIT_IN2_BASE), BIT_IN2_COUNT
if BIT_OUT2_BASE <= address < BIT_OUT2_BASE + BIT_OUT2_COUNT:
return "out", BIT_OUT1_COUNT + (address - BIT_OUT2_BASE), BIT_OUT2_COUNT
raise AddressError(address)
# -- helpers for the scenario engine ---------------------------------------
def digital_input_channel(self, channel: int) -> bool:
"""Digital input channel number (1-based) -> state."""
return self._bit_value(
self._in_words, self._digital_in_word_offset, channel - 1
)
def set_digital_input_channel(self, channel: int, value: bool) -> None:
self._set_bit(
self._in_words, self._digital_in_word_offset, channel - 1, value
)
def digital_output_channel(self, channel: int) -> bool:
return self._bit_value(
self._out_words, self._digital_out_word_offset, channel - 1
)
@staticmethod
def _bit_value(words: list[int], offset: int, bit_index: int) -> bool:
return bool(words[offset + bit_index // 16] & (1 << (bit_index % 16)))
@staticmethod
def _set_bit(words: list[int], offset: int, bit_index: int, value: bool) -> None:
w = offset + bit_index // 16
if value:
words[w] = words[w] | (1 << (bit_index % 16))
else:
words[w] = words[w] & ~(1 << (bit_index % 16)) & 0xFFFF
@staticmethod
def _validate_index(index: int, size: int) -> None:
if not 0 <= index < size:
raise IndexError(f"word index out of range: {index}")
+125
View File
@@ -0,0 +1,125 @@
"""MBAP (Modbus Application Protocol) header codec for Modbus TCP.
Frame layout (7-byte header + PDU):
Bytes 0-1 Transaction identifier (echoed by server)
Bytes 2-3 Protocol identifier (0 for Modbus)
Bytes 4-5 Length field (bytes following, i.e. unit id + PDU)
Byte 6 Unit identifier
Bytes 7.. PDU (function code + data), max 253 bytes
"""
from __future__ import annotations
from dataclasses import dataclass
MODBUS_PORT = 502
PROTOCOL_ID = 0
MAX_PDU_SIZE = 253
MBAP_HEADER_SIZE = 7
class MbapError(ValueError):
"""Raised for structurally invalid MBAP frames."""
@dataclass(slots=True, frozen=True)
class MbapHeader:
"""Decoded MBAP header."""
transaction_id: int
protocol_id: int
length: int
unit_id: int
@property
def pdu_size(self) -> int:
"""Bytes of PDU following the header (length - 1)."""
return self.length - 1
def encode(self) -> bytes:
if not 0 <= self.transaction_id <= 0xFFFF:
raise MbapError(f"transaction id out of range: {self.transaction_id}")
if self.protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id must be 0, got {self.protocol_id}")
if not 1 <= self.length <= MAX_PDU_SIZE + 1:
raise MbapError(f"length out of range: {self.length}")
return (
self.transaction_id.to_bytes(2, "big")
+ self.protocol_id.to_bytes(2, "big")
+ self.length.to_bytes(2, "big")
+ self.unit_id.to_bytes(1, "big")
)
@staticmethod
def decode(buf: bytes) -> MbapHeader:
"""Decode a complete frame buffer (header + PDU)."""
if len(buf) < MBAP_HEADER_SIZE:
raise MbapError(f"frame too short for MBAP header: {len(buf)} bytes")
transaction_id = int.from_bytes(buf[0:2], "big")
protocol_id = int.from_bytes(buf[2:4], "big")
length = int.from_bytes(buf[4:6], "big")
unit_id = buf[6]
if protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id != 0: {protocol_id}")
if length < 2:
raise MbapError(f"length field too small: {length}")
if length - 1 > MAX_PDU_SIZE:
raise MbapError(f"length field too large: {length}")
if len(buf) < MBAP_HEADER_SIZE + length - 1:
raise MbapError(
f"frame incomplete: header announces {length - 1} PDU bytes, "
f"got {len(buf) - MBAP_HEADER_SIZE}"
)
return MbapHeader(transaction_id, protocol_id, length, unit_id)
def build_frame(transaction_id: int, unit_id: int, pdu: bytes) -> bytes:
"""Build a complete Modbus TCP frame from header fields and a PDU."""
header = MbapHeader(
transaction_id=transaction_id,
protocol_id=PROTOCOL_ID,
length=1 + len(pdu),
unit_id=unit_id,
)
return header.encode() + pdu
class MbapFrameReader:
"""Incremental frame reassembly from a byte stream.
Feed arbitrary chunks with :meth:`feed`; extract complete frames
with :meth:`pop_frame` until it returns ``None``.
"""
def __init__(self, max_frame_size: int = MBAP_HEADER_SIZE + MAX_PDU_SIZE) -> None:
self._buf = bytearray()
self._max = max_frame_size
def feed(self, chunk: bytes) -> None:
self._buf.extend(chunk)
if len(self._buf) > self._max * 4: # generous garbage guard
raise MbapError(
f"receive buffer exceeded {self._max * 4} bytes without a valid frame"
)
def pending(self) -> int:
return len(self._buf)
def pop_frame(self) -> tuple[MbapHeader, bytes] | None:
"""Return the next complete (header, pdu) or None if incomplete."""
if len(self._buf) < MBAP_HEADER_SIZE:
return None
protocol_id = int.from_bytes(self._buf[2:4], "big")
length = int.from_bytes(self._buf[4:6], "big")
if protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id != 0: {protocol_id}")
if length < 2 or length - 1 > MAX_PDU_SIZE:
raise MbapError(f"invalid length field: {length}")
total = MBAP_HEADER_SIZE + length - 1
if len(self._buf) < total:
return None
header = MbapHeader.decode(bytes(self._buf[:total]))
pdu = bytes(self._buf[MBAP_HEADER_SIZE:total])
del self._buf[:total]
return header, pdu
+281
View File
@@ -0,0 +1,281 @@
"""Virtual WAGO I/O modules and process-image layout.
The 750-362 builds its process image as follows (manual §7.3.2.1):
1. Complex (word-oriented) modules are laid out first, in physical
order, starting at word 0 — separately for inputs and outputs.
2. Bit-oriented modules follow, byte-packed in physical order; a byte
fills with channels until 8 bits are complete, then the next byte
starts.
Digital Modbus bit addresses equal the *channel numbers*, independent
of the physical layout (manual §11.2.4): DI channel 47 -> bit address 46.
This module provides a small catalog of common WAGO 750 modules with
their process-data widths, and :class:`VirtualNode`, which maps a
configured module list onto the datastore's process images.
"""
from __future__ import annotations
from dataclasses import dataclass
from .datastore import WagoDatastore
@dataclass(frozen=True)
class Channel:
"""One I/O channel of a module."""
name: str
kind: str # "di" | "do" | "ai" | "ao"
@dataclass(frozen=True)
class ModuleType:
"""Process-data description of a WAGO I/O module."""
article: str # e.g. "750-430"
description: str
input_words: int # word-oriented input data (analog modules)
output_words: int # word-oriented output data (analog modules)
input_bits: int # bit-oriented input channels (digital)
output_bits: int # bit-oriented output channels (digital)
channels: tuple[Channel, ...] = ()
@property
def is_complex(self) -> bool:
"""Word-oriented modules are laid out first in the image."""
return self.input_words > 0 or self.output_words > 0
# --- catalog (subset; extend per project needs) ------------------------------
DI_2 = ModuleType("750-400", "2-channel DI 24VDC", 0, 0, 2, 0,
(Channel("DI 1", "di"), Channel("DI 2", "di")))
DI_4 = ModuleType("750-402", "4-channel DI 24VDC", 0, 0, 4, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 5)))
DI_8 = ModuleType("750-430", "8-channel DI 24VDC", 0, 0, 8, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 9)))
DI_16 = ModuleType("750-1405", "16-channel DI 24VDC", 0, 0, 16, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 17)))
DO_2 = ModuleType("750-501", "2-channel DO 24VDC", 0, 0, 0, 2,
(Channel("DO 1", "do"), Channel("DO 2", "do")))
DO_4 = ModuleType("750-504", "4-channel DO 24VDC", 0, 0, 0, 4,
tuple(Channel(f"DO {i}", "do") for i in range(1, 5)))
DO_8 = ModuleType("750-530", "8-channel DO 24VDC", 0, 0, 0, 8,
tuple(Channel(f"DO {i}", "do") for i in range(1, 9)))
DO_16 = ModuleType("750-1516", "16-channel DO 24VDC", 0, 0, 0, 16,
tuple(Channel(f"DO {i}", "do") for i in range(1, 17)))
AI_1 = ModuleType("750-461", "1-channel AI Pt100", 1, 0, 0, 0,
(Channel("AI 1", "ai"),))
AI_2 = ModuleType("750-467", "2-channel AI 4-20mA", 2, 0, 0, 0,
tuple(Channel(f"AI {i}", "ai") for i in range(1, 3)))
AI_4 = ModuleType("750-466", "4-channel AI 0-10V", 4, 0, 0, 0,
tuple(Channel(f"AI {i}", "ai") for i in range(1, 5)))
AO_2 = ModuleType("750-554", "2-channel AO 4-20mA", 0, 2, 0, 0,
tuple(Channel(f"AO {i}", "ao") for i in range(1, 3)))
AO_4 = ModuleType("750-556", "4-channel AO 0-10V", 0, 4, 0, 0,
tuple(Channel(f"AO {i}", "ao") for i in range(1, 5)))
CATALOG: dict[str, ModuleType] = {m.article: m for m in (
DI_2, DI_4, DI_8, DI_16, DO_2, DO_4, DO_8, DO_16,
AI_1, AI_2, AI_4, AO_2, AO_4,
)}
def resolve_module(name: str) -> ModuleType:
"""Look up a module by article number (e.g. '750-430')."""
try:
return CATALOG[name]
except KeyError:
raise ModuleUnknown(name) from None
class ModuleUnknown(ValueError):
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"unknown module {name!r}; known: {', '.join(sorted(CATALOG))}"
)
class ChannelMismatch(ValueError):
"""The requested channel kind does not exist on the module."""
@dataclass
class ChannelLocation:
"""Resolved address of a channel for scenario aliases."""
kind: str # "di" | "do" | "ai" | "ao"
module_index: int # position in the node's module list
channel: int # 1-based channel number on the module
word: int = -1 # for analog channels: word index in the image
bit: int = -1 # for digital channels: global bit index (0-based)
class VirtualNode:
"""A configured bus coupler with its I/O modules.
Computes the WAGO process-image layout for a module list and binds
it to a :class:`WagoDatastore`.
"""
def __init__(self, datastore: WagoDatastore, modules: list[ModuleType]) -> None:
self.datastore = datastore
self.modules = modules
self._di_channels: list[ChannelLocation] = []
self._do_channels: list[ChannelLocation] = []
self._ai_channels: list[ChannelLocation] = []
self._ao_channels: list[ChannelLocation] = []
self._layout()
def _layout(self) -> None:
complex_mods = [m for m in self.modules if m.is_complex]
bit_mods = [m for m in self.modules if not m.is_complex]
# --- word area (analog modules first, physical order) -------------
in_word = 0
out_word = 0
analog_in_bits = 0
analog_out_bits = 0
for m in complex_mods:
for c in range(m.input_words):
self._ai_channels.append(
ChannelLocation("ai", self.modules.index(m), c + 1, word=in_word)
)
in_word += 1
analog_in_bits += 16
for c in range(m.output_words):
self._ao_channels.append(
ChannelLocation("ao", self.modules.index(m), c + 1, word=out_word)
)
out_word += 1
analog_out_bits += 16
# --- bit area (digital modules byte-packed after analog words) -----
di_bit = 0
do_bit = 0
digital_in_bits = 0
digital_out_bits = 0
for m in bit_mods:
for c in range(m.input_bits):
self._di_channels.append(
ChannelLocation("di", self.modules.index(m), c + 1, bit=di_bit)
)
di_bit += 1
digital_in_bits += 1
for c in range(m.output_bits):
self._do_channels.append(
ChannelLocation("do", self.modules.index(m), c + 1, bit=do_bit)
)
do_bit += 1
digital_out_bits += 1
# tell the datastore where the byte-packed digital areas start:
# digital channels are stored AFTER the analog words (§7.3.2.1)
self.datastore.set_digital_word_offsets(
in_words=in_word, out_words=out_word
)
# register 0x1022-0x1025 report these sizes
self.analog_in_bits = analog_in_bits
self.analog_out_bits = analog_out_bits
self.digital_in_bits = digital_in_bits
self.digital_out_bits = digital_out_bits
def description(self) -> str:
"""Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
# -- named channel access (scenario engine API) -----------------------------
@property
def di(self) -> list[ChannelLocation]:
return self._di_channels
@property
def do(self) -> list[ChannelLocation]:
return self._do_channels
@property
def ai(self) -> list[ChannelLocation]:
return self._ai_channels
@property
def ao(self) -> list[ChannelLocation]:
return self._ao_channels
def channel(self, kind: str, index: int) -> ChannelLocation:
"""Access a channel by kind ('di'/'do'/'ai'/'ao') and 1-based index."""
pools = {"di": self._di_channels, "do": self._do_channels,
"ai": self._ai_channels, "ao": self._ao_channels}
pool = pools[kind]
if not 1 <= index <= len(pool):
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
return pool[index - 1]
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
"""Access a channel by module position and channel number, both 0-based.
Addresses a channel as *module type + module number + channel
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
The module number is the position in the configured module list;
the channel number is the channel on that module (0-based), i.e.
DI0.1 selects the module's 2nd digital input.
The channel kind must match what the module actually provides —
DI0.1 on a module with no inputs is a configuration error.
"""
if not 0 <= module < len(self.modules):
raise IndexError(
f"no module {module} (node has {len(self.modules)} modules, "
f"numbered 0-{len(self.modules) - 1})"
)
mod = self.modules[module]
pool_map = {
"di": (self._di_channels, mod.input_bits),
"do": (self._do_channels, mod.output_bits),
"ai": (self._ai_channels, mod.input_words),
"ao": (self._ao_channels, mod.output_words),
}
if kind not in pool_map:
raise ValueError(f"unknown channel kind: {kind!r}")
pool, module_channel_count = pool_map[kind]
if module_channel_count == 0:
raise ChannelMismatch(
f"module {module} ({mod.article}) has no {kind.upper()} channels"
)
if not 0 <= channel < module_channel_count:
raise IndexError(
f"module {module} ({mod.article}) has no {kind.upper()} "
f"channel {channel} (has {module_channel_count}: 0-"
f"{module_channel_count - 1})"
)
# find the location: channels of one module are contiguous in the
# kind pool; locate the first channel of this module in the pool
for loc in pool:
if loc.module_index == module and loc.channel == channel + 1:
return loc
raise ChannelMismatch(
f"module {module} ({mod.article}) channel {channel} is not a "
f"{kind.upper()} channel"
)
# -- field-side setters (simulated world) -------------------------------------
def set_di(self, channel: int, value: bool) -> None:
"""Digital channels are global bit indexes (channel numbers - 1):
the datastore applies its digital word offset."""
self.datastore.set_digital_input_channel(channel, value)
def set_ai(self, channel: int, value: int) -> None:
loc = self.channel("ai", channel)
self.datastore.set_input_word(loc.word, value)
def get_do(self, channel: int) -> bool:
return self.datastore.digital_output_channel(channel)
def get_ao(self, channel: int) -> int:
loc = self.channel("ao", channel)
return self.datastore.get_output_word(loc.word)
+392
View File
@@ -0,0 +1,392 @@
"""Modbus PDU (Protocol Data Unit) codec.
Encodes/decodes request and response PDUs for the function codes
supported by the WAGO 750-362 coupler:
FC1 Read Coils (max 2000 bits)
FC2 Read Discrete Inputs (max 2000 bits)
FC3 Read Holding Registers (max 125 words)
FC4 Read Input Registers (max 125 words)
FC5 Write Single Coil
FC6 Write Single Register
FC11 Get Comm Event Count
FC15 Write Multiple Coils (max 800 bits)
FC16 Write Multiple Registers (max 100 words)
FC22 Mask Write Register
FC23 Read/Write Multiple Registers (125 read / 100 write)
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Function codes supported by the WAGO 750-362 (manual §11.2.2)
FC_READ_COILS = 0x01
FC_READ_DISCRETE_INPUTS = 0x02
FC_READ_HOLDING_REGISTERS = 0x03
FC_READ_INPUT_REGISTERS = 0x04
FC_WRITE_SINGLE_COIL = 0x05
FC_WRITE_SINGLE_REGISTER = 0x06
FC_GET_COMM_EVENT_COUNT = 0x0B
FC_WRITE_MULTIPLE_COILS = 0x0F
FC_WRITE_MULTIPLE_REGISTERS = 0x10
FC_MASK_WRITE_REGISTER = 0x16
FC_READ_WRITE_MULTIPLE_REGISTERS = 0x17
SUPPORTED_FUNCTION_CODES = frozenset(
{
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_GET_COMM_EVENT_COUNT,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_MASK_WRITE_REGISTER,
FC_READ_WRITE_MULTIPLE_REGISTERS,
}
)
# Access limits per 750-362 (statistics counters TooManyRegisters/TooManyBits)
MAX_READ_BITS = 2000
MAX_WRITE_BITS = 800
MAX_READ_REGISTERS = 125
MAX_WRITE_REGISTERS = 100
# Exception codes (manual §11.2.3, Table 83)
EXC_ILLEGAL_FUNCTION = 0x01
EXC_ILLEGAL_DATA_ADDRESS = 0x02
EXC_ILLEGAL_DATA_VALUE = 0x03
EXC_SLAVE_DEVICE_FAILURE = 0x04
EXC_ACKNOWLEDGE = 0x05
EXC_SERVER_BUSY = 0x06
COIL_ON = 0xFF00
COIL_OFF = 0x0000
class PduError(ValueError):
"""Raised for structurally invalid PDUs."""
@dataclass(slots=True, frozen=True)
class ReadRequest:
"""FC1/2/3/4 common request shape."""
function_code: int
address: int
count: int
@dataclass(slots=True, frozen=True)
class WriteSingleCoilRequest:
function_code: int = FC_WRITE_SINGLE_COIL
address: int = 0
value: bool = False
@dataclass(slots=True, frozen=True)
class WriteSingleRegisterRequest:
function_code: int = FC_WRITE_SINGLE_REGISTER
address: int = 0
value: int = 0
@dataclass(slots=True, frozen=True)
class WriteMultipleCoilsRequest:
function_code: int = FC_WRITE_MULTIPLE_COILS
address: int = 0
values: tuple[bool, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class WriteMultipleRegistersRequest:
function_code: int = FC_WRITE_MULTIPLE_REGISTERS
address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class MaskWriteRegisterRequest:
function_code: int = FC_MASK_WRITE_REGISTER
address: int = 0
and_mask: int = 0xFFFF
or_mask: int = 0x0000
@dataclass(slots=True, frozen=True)
class ReadWriteMultipleRegistersRequest:
function_code: int = FC_READ_WRITE_MULTIPLE_REGISTERS
read_address: int = 0
read_count: int = 0
write_address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class GetCommEventCountRequest:
function_code: int = FC_GET_COMM_EVENT_COUNT
Request = (
ReadRequest
| WriteSingleCoilRequest
| WriteSingleRegisterRequest
| WriteMultipleCoilsRequest
| WriteMultipleRegistersRequest
| MaskWriteRegisterRequest
| ReadWriteMultipleRegistersRequest
| GetCommEventCountRequest
)
class ReadResponse:
"""Response for FC1/2 (bits) or FC3/4 (words)."""
__slots__ = ("function_code", "bits", "registers")
def __init__(self, function_code: int, bits: list[bool] | None = None,
registers: list[int] | None = None) -> None:
self.function_code = function_code
self.bits = bits if bits is not None else []
self.registers = registers if registers is not None else []
def encode(self) -> bytes:
if self.function_code in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS):
payload = self._pack_bits(self.bits)
return bytes([self.function_code, len(payload)]) + payload
data = b"".join(v.to_bytes(2, "big") for v in self.registers)
byte_count = len(data)
if byte_count != 2 * len(self.registers):
raise PduError("register count mismatch")
return bytes([self.function_code, byte_count]) + data
@staticmethod
def _pack_bits(bits: list[bool]) -> bytes:
out = bytearray((len(bits) + 7) // 8)
for i, bit in enumerate(bits):
if bit:
out[i // 8] |= 1 << (i % 8)
return bytes(out)
class WriteSingleCoilResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: bool) -> None:
self.function_code = FC_WRITE_SINGLE_COIL
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ (COIL_ON if self.value else COIL_OFF).to_bytes(2, "big")
)
class WriteSingleRegisterResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: int) -> None:
self.function_code = FC_WRITE_SINGLE_REGISTER
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.value.to_bytes(2, "big")
)
class WriteMultipleResponse:
__slots__ = ("function_code", "address", "count")
def __init__(self, function_code: int, address: int, count: int) -> None:
self.function_code = function_code
self.address = address
self.count = count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.count.to_bytes(2, "big")
)
class MaskWriteRegisterResponse:
__slots__ = ("function_code", "address", "and_mask", "or_mask")
def __init__(self, address: int, and_mask: int, or_mask: int) -> None:
self.function_code = FC_MASK_WRITE_REGISTER
self.address = address
self.and_mask = and_mask
self.or_mask = or_mask
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.and_mask.to_bytes(2, "big")
+ self.or_mask.to_bytes(2, "big")
)
class GetCommEventCountResponse:
__slots__ = ("function_code", "status", "event_count")
def __init__(self, status: int = 0, event_count: int = 0) -> None:
self.function_code = FC_GET_COMM_EVENT_COUNT
self.status = status
self.event_count = event_count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.status.to_bytes(2, "big")
+ self.event_count.to_bytes(2, "big")
)
class ExceptionResponse:
__slots__ = ("function_code", "exception_code")
def __init__(self, function_code: int, exception_code: int) -> None:
self.function_code = function_code
self.exception_code = exception_code
def encode(self) -> bytes:
return bytes([self.function_code | 0x80, self.exception_code])
Response = (
ReadResponse
| WriteSingleCoilResponse
| WriteSingleRegisterResponse
| WriteMultipleResponse
| MaskWriteRegisterResponse
| GetCommEventCountResponse
| ExceptionResponse
)
def decode_request(pdu: bytes) -> Request:
"""Decode a request PDU.
Raises :class:`PduError` for structurally invalid PDUs
(the caller then answers with exception 0x03).
Unsupported function codes raise :class:`UnsupportedFunction`,
a subclass, so the server can reply 0x01 while counting BadFunction.
"""
if not pdu:
raise PduError("empty PDU")
fc = pdu[0]
if fc not in SUPPORTED_FUNCTION_CODES:
raise UnsupportedFunction(fc)
body = pdu[1:]
if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS, FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS):
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
max_count = (
MAX_READ_BITS if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS)
else MAX_READ_REGISTERS
)
if count < 1 or count > max_count:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
return ReadRequest(fc, address, count)
if fc == FC_WRITE_SINGLE_COIL:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
raw = int.from_bytes(body[2:4], "big")
if raw not in (COIL_ON, COIL_OFF):
raise IllegalDataValue(f"FC{fc}: invalid coil value: {raw:#06x}")
return WriteSingleCoilRequest(fc, address, raw == COIL_ON)
if fc == FC_WRITE_SINGLE_REGISTER:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
value = int.from_bytes(body[2:4], "big")
return WriteSingleRegisterRequest(fc, address, value)
if fc == FC_GET_COMM_EVENT_COUNT:
if body:
raise PduError(f"FC{fc}: no data bytes expected, got {len(body)}")
return GetCommEventCountRequest(fc)
if fc == FC_WRITE_MULTIPLE_COILS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_BITS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != (count + 7) // 8 or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = [
bool(body[5 + i // 8] & (1 << (i % 8))) for i in range(count)
]
return WriteMultipleCoilsRequest(fc, address, tuple(values))
if fc == FC_WRITE_MULTIPLE_REGISTERS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != 2 * count or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[5 + 2 * i:7 + 2 * i], "big") for i in range(count)
)
return WriteMultipleRegistersRequest(fc, address, values)
if fc == FC_MASK_WRITE_REGISTER:
if len(body) != 6:
raise PduError(f"FC{fc}: expected 6 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
and_mask = int.from_bytes(body[2:4], "big")
or_mask = int.from_bytes(body[4:6], "big")
return MaskWriteRegisterRequest(fc, address, and_mask, or_mask)
if fc == FC_READ_WRITE_MULTIPLE_REGISTERS:
if len(body) < 10:
raise PduError(f"FC{fc}: too short: {len(body)}")
read_address = int.from_bytes(body[0:2], "big")
read_count = int.from_bytes(body[2:4], "big")
write_address = int.from_bytes(body[4:6], "big")
write_count = int.from_bytes(body[6:8], "big")
byte_count = body[8] # 1-byte field per Modbus spec
if read_count < 1 or read_count > MAX_READ_REGISTERS:
raise IllegalDataValue(f"FC{fc}: read count out of range: {read_count}")
if write_count < 1 or write_count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: write count out of range: {write_count}")
if byte_count != 2 * write_count or len(body) != 9 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[9 + 2 * i:11 + 2 * i], "big")
for i in range(write_count)
)
return ReadWriteMultipleRegistersRequest(
fc, read_address, read_count, write_address, values
)
raise UnsupportedFunction(fc) # pragma: no cover
class UnsupportedFunction(PduError):
"""Function code not implemented by the 750-362."""
def __init__(self, function_code: int) -> None:
self.function_code = function_code
super().__init__(f"unsupported function code: {function_code:#04x}")
class IllegalDataValue(PduError):
"""Field value outside allowed range (exception 0x03 path)."""
+262
View File
@@ -0,0 +1,262 @@
"""Configuration, diagnostic, constant, and firmware-info register bank.
Implements the 0x1000-0x2FFF area of the WAGO 750-362 (manual §11.2.5).
Watchdog registers 0x1000-0x100B are delegated to :class:`ModbusWatchdog`.
"""
from __future__ import annotations
from collections.abc import Callable
from .stats import STATS_REGISTER, STATS_WORD_COUNT, ModbusStatistics
from .watchdog import ModbusWatchdog, RegisterWriteRefused
# --- register addresses -------------------------------------------------------
REG_LED_ERROR_CODE = 0x1020
REG_LED_ERROR_ARG = 0x1021
REG_ANALOG_OUT_BITS = 0x1022
REG_ANALOG_IN_BITS = 0x1023
REG_DIGITAL_OUT_BITS = 0x1024
REG_DIGITAL_IN_BITS = 0x1025
REG_TCP_CONNECTIONS = 0x102A
REG_KBUS_RESET = 0x102B
REG_TCP_IDLE_TIMEOUT = 0x1030
REG_MAC_ID = 0x1031
REG_RESPONSE_DELAY = 0x1037
REG_TOS = 0x1038
REG_MODULE_DIAG = 0x1050
# constants 0x2000-0x2008
CONSTANTS = {
0x2000: 0x0000,
0x2001: 0xFFFF,
0x2002: 0x1234,
0x2003: 0xAAAA,
0x2004: 0x5555,
0x2005: 0x7FFF,
0x2006: 0x8000,
0x2007: 0x3FFF,
0x2008: 0x4000,
}
# firmware info 0x2010-0x2043
REG_FW_REVISION = 0x2010
REG_FW_SERIES = 0x2011
REG_FW_ITEM = 0x2012
REG_FW_MAJOR = 0x2013
REG_FW_MINOR = 0x2014
REG_FW_DESCRIPTION = 0x2020 # up to 16 words
REG_FW_COMPILE_TIME = 0x2021 # up to 8 words
REG_FW_COMPILE_DATE = 0x2022 # up to 8 words
REG_FW_LOADER = 0x2023 # up to 32 words
REG_MODULE_DESC_0_64 = 0x2030 # 65 words
REG_PROCESS_IMAGE_SETTINGS = 0x2035
REG_DIAGNOSTICS = 0x2036
REG_SOFTWARE_RESET = 0x2040 # write 0x55AA/0xAA55
REG_FACTORY_SETTINGS = 0x2043
RESET_KEYS = (0x55AA, 0xAA55)
SERIES_CODE_750 = 0x02EE # 750 dec
COUPLER_CODE_362 = 0x016A # 362 dec
def _string_to_words(text: str, max_words: int) -> list[int]:
"""Pack ASCII into 16-bit words (2 chars/word, high byte first)."""
data = text.encode("ascii", errors="replace")[: 2 * max_words]
data = data.ljust(2 * max_words, b"\x00")
return [int.from_bytes(data[2 * i : 2 * i + 2], "big") for i in range(max_words)]
class ConfigRegisterBank:
"""Read/write dispatch for the 0x1000-0x2FFF configuration area."""
def __init__(
self,
*,
statistics: ModbusStatistics,
watchdog: ModbusWatchdog,
tcp_connection_counter: Callable[[], int],
mac_id: tuple[int, int, int, int, int, int] = (0x00, 0x30, 0xDE, 0x11, 0x22, 0x33),
response_delay_ms: int = 0,
idle_timeout_units: int = 600,
process_image_bits: tuple[int, int, int, int] = (0, 0, 0, 0),
firmware_description: str = "WAGO 750-362 Simulator",
on_software_reset: Callable[[], None] | None = None,
on_kbus_reset: Callable[[], None] | None = None,
) -> None:
# (analog_in_bits, analog_out_bits, digital_in_bits, digital_out_bits)
self._process_image_bits = process_image_bits
self._stats = statistics
self._watchdog = watchdog
self._tcp_connections = tcp_connection_counter
self._mac_id = mac_id
self._response_delay_ms = response_delay_ms
self._idle_timeout_units = idle_timeout_units
self._led_error_code = 0
self._led_error_arg = 0
self._fw = {
REG_FW_REVISION: 0x0001,
REG_FW_SERIES: SERIES_CODE_750,
REG_FW_ITEM: COUPLER_CODE_362,
REG_FW_MAJOR: 0x0000,
REG_FW_MINOR: 0x0001,
}
self._description = _string_to_words(firmware_description, 16)
self._compile_time = _string_to_words("10:00:00", 8)
self._compile_date = _string_to_words("2026-10-03", 8)
self._loader = _string_to_words("boot", 32)
self._module_desc: list[int] = [0] * 65
self._module_diag: tuple[int, int, int] = (0, 0, 0) # module, channel, diag
self._on_software_reset = on_software_reset
self._on_kbus_reset = on_kbus_reset
# -- image geometry (set by the node after module layout) -------------------
def set_process_image_bits(
self, analog_in: int, analog_out: int, digital_in: int, digital_out: int
) -> None:
self._process_image_bits = (analog_in, analog_out, digital_in, digital_out)
def set_module_description(self, text: str) -> None:
self._module_desc = _string_to_words(text, 65)
def set_led_error(self, code: int, argument: int = 0) -> None:
self._led_error_code = code & 0xFFFF
self._led_error_arg = argument & 0xFFFF
def set_module_diagnosis(self, module: int, channel: int, diagnosis: int) -> None:
"""Fault injection for register 0x1050."""
self._module_diag = (module & 0xFFFF, channel & 0xFFFF, diagnosis & 0xFFFF)
@property
def response_delay_ms(self) -> int:
return self._response_delay_ms
@property
def idle_timeout_ms(self) -> int:
return self._idle_timeout_units * 100
# -- register access -----------------------------------------------------------
def read_config(self, address: int, count: int) -> list[int]:
"""Read `count` words starting at `address`.
The 9-word statistics block (0x1029-0x1031) overlaps dedicated
registers in the manual (0x102A connections, 0x1031 MAC-ID):
a read *starting at* 0x1029 returns the statistics block as a
unit; individual addresses keep their dedicated meanings.
"""
out: list[int] = []
if address <= STATS_REGISTER and address + count > STATS_REGISTER:
out.extend(self._read_one(a) for a in range(address, STATS_REGISTER))
n_stats = min(count - len(out), STATS_WORD_COUNT)
out.extend(self._stats.as_words()[:n_stats])
rest_at = STATS_REGISTER + n_stats
out.extend(self._read_one(a) for a in range(rest_at, address + count))
return out
return [self._read_one(a) for a in range(address, address + count)]
def write_config(self, address: int, values: list[int]) -> None:
for i, value in enumerate(values):
self._write_one(address + i, value)
def _read_one(self, address: int) -> int:
# watchdog block
if 0x1000 <= address <= 0x100B:
return self._watchdog.read(address)
# diagnostics
if address == REG_LED_ERROR_CODE:
return self._led_error_code
if address == REG_LED_ERROR_ARG:
return self._led_error_arg
# image sizes
if address == REG_ANALOG_IN_BITS:
return self._process_image_bits[0] & 0xFFFF
if address == REG_ANALOG_OUT_BITS:
return self._process_image_bits[1] & 0xFFFF
if address == REG_DIGITAL_IN_BITS:
return self._process_image_bits[2] & 0xFFFF
if address == REG_DIGITAL_OUT_BITS:
return self._process_image_bits[3] & 0xFFFF
# statistics
if address == STATS_REGISTER:
return self._stats.slave_device_failure # first word
if address == REG_TCP_CONNECTIONS:
return self._tcp_connections()
if address == REG_KBUS_RESET:
return 0
if address == REG_TCP_IDLE_TIMEOUT:
return self._idle_timeout_units
if REG_MAC_ID <= address < REG_MAC_ID + 3:
return self._mac_id_word(address - REG_MAC_ID)
if address == REG_RESPONSE_DELAY:
return self._response_delay_ms
if address == REG_TOS:
return 0
if address == REG_MODULE_DIAG:
return self._module_diag[0]
if REG_MODULE_DIAG < address < REG_MODULE_DIAG + 3:
return self._module_diag[address - REG_MODULE_DIAG]
# constants
if address in CONSTANTS:
return CONSTANTS[address]
# firmware info
if address in self._fw:
return self._fw[address]
if REG_FW_DESCRIPTION <= address < REG_FW_DESCRIPTION + 16:
return self._description[address - REG_FW_DESCRIPTION]
if REG_FW_COMPILE_TIME <= address < REG_FW_COMPILE_TIME + 8:
return self._compile_time[address - REG_FW_COMPILE_TIME]
if REG_FW_COMPILE_DATE <= address < REG_FW_COMPILE_DATE + 8:
return self._compile_date[address - REG_FW_COMPILE_DATE]
if REG_FW_LOADER <= address < REG_FW_LOADER + 32:
return self._loader[address - REG_FW_LOADER]
if REG_MODULE_DESC_0_64 <= address < REG_MODULE_DESC_0_64 + 65:
return self._module_desc[address - REG_MODULE_DESC_0_64]
if address == REG_PROCESS_IMAGE_SETTINGS:
return 0
if address == REG_DIAGNOSTICS:
return 0
# undefined registers within the config area read as 0
return 0
def _mac_id_word(self, index: int) -> int:
"""Word i of the MAC-ID register (0x1031, 3 words).
The register exposes the first 3 MAC bytes as words (one byte per
word, per the manual's 3-word layout for 6-byte MACs).
"""
return self._mac_id[index] << 8
def _write_one(self, address: int, value: int) -> None:
value &= 0xFFFF
if 0x1000 <= address <= 0x100B:
self._watchdog.write(address, value)
return
if address == STATS_REGISTER:
self._stats.handle_reset_write(value)
return
if address == REG_TCP_IDLE_TIMEOUT:
self._idle_timeout_units = value
return
if address == REG_RESPONSE_DELAY:
self._response_delay_ms = value
return
if address == REG_TOS:
return # stored; echo on read is 0 in v1
if address == REG_KBUS_RESET:
if value in RESET_KEYS:
if self._on_kbus_reset:
self._on_kbus_reset()
return
if address == REG_SOFTWARE_RESET:
if value in RESET_KEYS:
if self._on_software_reset:
self._on_software_reset()
return
if address == REG_FACTORY_SETTINGS:
return
# remaining registers are read-only
raise RegisterWriteRefused(f"register {address:#06x} is read-only")
+1
View File
@@ -0,0 +1 @@
"""Scenario package: YAML DSL model, engine, actions, assertions, reporting."""
+89
View File
@@ -0,0 +1,89 @@
"""YAML scenario/node configuration loading and validation."""
from __future__ import annotations
from pathlib import Path
import yaml
from ..modules import ModuleType, resolve_module
from .engine import ScenarioError
def load_yaml(path: str | Path) -> dict:
path = Path(path)
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ScenarioError(f"{path}: top-level YAML must be a mapping")
return data
def load_node_modules(node_config: dict) -> list[ModuleType]:
"""Build the module list from a node configuration dict.
Accepted shape::
modules: ["750-430", "750-530", ...] # short form
# or
modules:
- article: "750-461"
count: 2
"""
modules: list[ModuleType] = []
entries = node_config.get("modules")
if not entries:
raise ScenarioError("node config needs a 'modules' list")
for entry in entries:
if isinstance(entry, str):
modules.append(resolve_module(entry))
elif isinstance(entry, dict):
article = entry.get("article")
if not article:
raise ScenarioError(f"module entry needs 'article': {entry!r}")
count = int(entry.get("count", 1))
module = resolve_module(str(article))
modules.extend([module] * count)
else:
raise ScenarioError(f"invalid module entry: {entry!r}")
if len(modules) > 64:
# 750-362 supports up to 64 I/O modules; keep the FAT bench realistic
raise ScenarioError(f"too many modules: {len(modules)} (max 64)")
return modules
def validate_scenario(scenario: dict) -> list[str]:
"""Structural validation; returns a list of problems (empty == valid)."""
problems: list[str] = []
if not scenario.get("name"):
problems.append("missing 'name'")
inputs = scenario.get("inputs") or {}
outputs = scenario.get("outputs") or {}
if not inputs and not outputs:
problems.append("no aliases defined under 'inputs'/'outputs'")
for group, kinds in (("inputs", {"di", "ai"}), ("outputs", {"do", "ao"})):
for name, spec in (scenario.get(group) or {}).items():
if not isinstance(spec, dict) or "type" not in spec or "channel" not in spec:
problems.append(f"{group}.{name}: needs 'type' and 'channel'")
continue
if spec["type"] not in kinds:
problems.append(
f"{group}.{name}: type {spec['type']!r} not in {sorted(kinds)}"
)
if "module" in spec:
# module-based addressing: both module and channel 0-based
if not isinstance(spec["module"], int) or spec["module"] < 0:
problems.append(
f"{group}.{name}: 'module' must be a non-negative "
f"integer (0-based module position)"
)
if not isinstance(spec["channel"], int) or spec["channel"] < 0:
problems.append(
f"{group}.{name}: 'channel' must be a non-negative "
f"integer (0-based channel on the module) when "
f"'module' is given"
)
seq = scenario.get("sequence") or []
if not seq and not scenario.get("states"):
problems.append("neither 'sequence' nor 'states' defined")
return problems
+569
View File
@@ -0,0 +1,569 @@
"""Scenario engine: DSL model and interpreter.
A scenario binds *named aliases* (start_button, pump_relay, ...) to node
channels, defines reusable *macros*, an optional *state machine*, and a
time-ordered *sequence* of steps with assertions. The engine observes the
PLC's output writes (through the datastore listener) and the simulated
inputs it drives itself, producing a PASS/FAIL report.
Execution model: a step queue is advanced one *blocking* step at a time.
Immediate steps (set/pulse/ramp/macro) are drained in the same tick;
`wait` and `assert` block until satisfied. Macros expand inline into the
queue, so their internal waits behave exactly like sequence waits.
See docs/spec/software-spec.md §4 for the YAML schema.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from ..datastore import WagoDatastore
from ..modules import VirtualNode
class Verdict(Enum):
PENDING = "PENDING"
PASS = "PASS"
FAIL = "FAIL"
ERROR = "ERROR"
@dataclass(frozen=True)
class Alias:
"""A named channel binding.
Two addressing schemes (mutually exclusive in the spec):
- *module-based*: ``module`` + ``channel`` — module position and
channel number on the module, both 0-based (e.g. ``DI0.1`` =
digital input, module slot 0, channel 1).
- *pool-based*: ``channel`` alone — 1-based index within the node's
kind pool (all DI channels counted together).
"""
name: str
kind: str # di | do | ai | ao
channel: int # 0-based channel on the module (module addressing),
# or 1-based pool index (pool addressing)
scale: float = 1.0
offset: float = 0.0
module: int | None = None # 0-based module position; None = pool addressing
@dataclass
class StepResult:
step: str
verdict: Verdict
detail: str = ""
sim_time_ms: float = 0.0
@dataclass
class ScenarioResult:
name: str
verdict: Verdict = Verdict.PENDING
steps: list[StepResult] = field(default_factory=list)
started: float = 0.0
duration_ms: float = 0.0
@property
def passed(self) -> bool:
return self.verdict is Verdict.PASS
class ConditionMatcher:
"""Evaluates assertion conditions against alias values."""
def __init__(self, resolve: Callable[[str], tuple[str, object]]) -> None:
self._resolve = resolve
def evaluate(self, spec: dict) -> bool:
"""spec example: {'pump_relay': 'ON'}, {'speed_setpoint': '>= 1000'}"""
for alias_name, expected in spec.items():
kind, current = self._resolve(alias_name)
if not self._match_one(kind, current, expected):
return False
return True
def _match_one(self, kind: str, current: object, expected: object) -> bool:
if kind in ("di", "do"):
cur = bool(current)
if isinstance(expected, str):
exp = expected.strip().upper() in ("ON", "TRUE", "1", "YES")
return cur == exp
return cur == bool(expected)
# analog
cur_val = float(current)
if isinstance(expected, str):
text = expected.strip()
for op in (">=", "<=", "!=", "==", ">", "<"):
if text.startswith(op):
return self._compare(cur_val, op, float(text[len(op):]))
if text.startswith("="):
return self._compare(cur_val, "==", float(text[1:]))
return cur_val == float(text)
return cur_val == float(expected)
@staticmethod
def _compare(cur: float, op: str, ref: float) -> bool:
return {
">=": cur >= ref,
"<=": cur <= ref,
">": cur > ref,
"<": cur < ref,
"!=": cur != ref,
"==": cur == ref,
}[op]
class ScenarioEngine:
"""Interprets a scenario against a virtual node + datastore.
The engine is intentionally synchronous and tick-driven: the host
application (CLI server or test) pumps :meth:`pump` while sim time
advances; Modbus writes are observed via the datastore listener so
assertions see them without polling races.
"""
TICK_MS = 10
def __init__(self, node: VirtualNode, scenario: dict, *, name: str = "") -> None:
self.node = node
self.datastore: WagoDatastore = node.datastore
self.scenario = scenario
self.name = name or scenario.get("name", "scenario")
self.sim_time_ms = 0.0
self._result = ScenarioResult(name=self.name)
self._aliases: dict[str, Alias] = {}
self._parse_aliases()
self._matcher = ConditionMatcher(self._resolve_alias)
self._last_snapshot = self._snapshot_aliases()
self._edges: dict[str, bool] = {} # rising-edge latches per alias
self._ramps: list[dict] = [] # active ramp generators
self._step_queue: deque = deque(scenario.get("sequence", []) or [])
self._wait_until_ms: float | None = None
self._pending_assert: dict | None = None
self._assert_deadline_ms: float | None = None
self._pending_edge: dict | None = None
self._finished = False
self._macros: dict[str, list] = {
k: v for k, v in (scenario.get("macros") or {}).items()
}
self._macro_return_wait: float | None = None
self._plugin_actions = self._load_plugins()
# -- setup ---------------------------------------------------------------
def _parse_aliases(self) -> None:
for group in ("inputs", "outputs"):
for name, spec in (self.scenario.get(group) or {}).items():
kind = str(spec["type"]).lower()
module = spec.get("module")
if module is not None:
# module-based addressing (FR-4.2.11): module position
# and channel number both 0-based, e.g. DI0.1
alias = Alias(
name=name,
kind=kind,
channel=int(spec["channel"]),
scale=float(spec.get("scale", 1.0)),
offset=float(spec.get("offset", 0.0)),
module=int(module),
)
# validate the channel exists on that module
self.node.channel_by_module(kind, alias.module, alias.channel)
else:
# pool-based addressing: 1-based index within the pool
alias = Alias(
name=name,
kind=kind,
channel=int(spec["channel"]),
scale=float(spec.get("scale", 1.0)),
offset=float(spec.get("offset", 0.0)),
)
# validate channel exists
self.node.channel(alias.kind, alias.channel)
self._aliases[name] = alias
def _alias_location(self, alias: Alias):
"""Resolve an alias to its ChannelLocation on the node."""
if alias.module is not None:
return self.node.channel_by_module(alias.kind, alias.module, alias.channel)
return self.node.channel(alias.kind, alias.channel)
def alias_location(self, name: str):
"""Public: ChannelLocation for an alias (for plant-model plugins).
``loc.bit`` is the global 0-based digital channel index (== the
Modbus bit address); ``loc.word`` is the global analog word index.
"""
return self._alias_location(self._aliases[name])
def _load_plugins(self) -> dict[str, Callable]:
"""Python action plugins.
Two mechanisms:
- `plugins: [module.function]` in the scenario header registers
*per-tick* plant models (called every pump with the engine).
- Any step key matching a registered function name invokes it as
a step action (FR-4.3.8).
"""
plugins: dict[str, Callable] = {}
import importlib
for ref in self.scenario.get("plugins", []) or []:
mod_name, _, fn_name = ref.rpartition(".")
mod = importlib.import_module(mod_name)
fn = getattr(mod, fn_name)
plugins[fn_name] = fn
return plugins
# -- alias resolution ------------------------------------------------------
def _resolve_alias(self, name: str) -> tuple[str, object]:
"""Resolve an alias to (kind, current value) for condition matching."""
alias = self._aliases[name]
return alias.kind, self.alias_value(name)
def alias_value(self, name: str) -> object:
alias = self._aliases[name]
ds = self.datastore
loc = self._alias_location(alias)
if alias.kind == "di":
# loc.bit is the global 0-based digital channel index
return ds.digital_input_channel(loc.bit + 1)
if alias.kind == "do":
return ds.digital_output_channel(loc.bit + 1)
if alias.kind == "ai":
raw = ds.get_input_word(loc.word)
return (raw - alias.offset) / alias.scale
# ao
raw = ds.get_output_word(loc.word)
return (raw - alias.offset) / alias.scale
def set_alias(self, name: str, value: object) -> None:
alias = self._aliases[name]
loc = self._alias_location(alias)
if alias.kind == "di":
# loc.bit is the global 0-based digital channel index; the
# datastore API takes the 1-based global channel number
self.datastore.set_digital_input_channel(loc.bit + 1, bool(value))
elif alias.kind == "ai":
raw = int(float(value) * alias.scale + alias.offset)
self.datastore.set_input_word(loc.word, raw & 0xFFFF)
else:
raise ScenarioError(
f"alias {name!r} is {alias.kind!r}; only di/ai are scriptable inputs"
)
# -- edge detection ------------------------------------------------------------
def _snapshot_aliases(self) -> dict[str, object]:
"""Snapshot every alias (inputs and outputs alike) for edges."""
return {name: self.alias_value(name) for name in self._aliases}
def _detect_edges(self) -> None:
current = self._snapshot_aliases()
for name, now in current.items():
before = self._last_snapshot.get(name)
# treat a missing snapshot entry as False so the very first
# ON transition (e.g. right after a set) is detected as an edge
if not bool(before) and bool(now):
self._edges[name] = True
self._last_snapshot = current
def consume_rising_edge(self, name: str) -> bool:
if self._edges.get(name):
self._edges[name] = False
return True
return False
# -- ramp generation --------------------------------------------------------
def start_ramp(self, name: str, to: float, over_ms: float) -> None:
alias = self._aliases[name]
start = float(self.alias_value(name))
self._ramps.append(
{
"name": name,
"from": start,
"to": to,
"over_ms": over_ms,
"start_ms": self.sim_time_ms,
"alias": alias,
}
)
def _advance_ramps(self, dt_ms: float) -> None:
still_active: list[dict] = []
for ramp in self._ramps:
elapsed = self.sim_time_ms - ramp["start_ms"]
if ramp.get("_mode") == "pulse":
if elapsed >= ramp["over_ms"]:
self.set_alias(ramp["name"], False)
continue
still_active.append(ramp)
continue
if elapsed >= ramp["over_ms"]:
self.set_alias(ramp["name"], ramp["to"])
continue
frac = elapsed / ramp["over_ms"] if ramp["over_ms"] else 1.0
value = ramp["from"] + (ramp["to"] - ramp["from"]) * frac
self.set_alias(ramp["name"], value)
still_active.append(ramp)
self._ramps = still_active
# -- main loop -----------------------------------------------------------------
def start(self) -> None:
self._result.started = time.time()
self._result.verdict = Verdict.PENDING
def pump(self, dt_ms: float | None = None) -> ScenarioResult | None:
"""Advance the scenario by one tick. Returns result when finished."""
if self._finished:
return self._result
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
self.sim_time_ms += dt
# 1) detect alias edges (inputs set in the previous tick, outputs
# written by the master/plant)
self._detect_edges()
# 2) plant models react to edges and drive outputs
self._run_tick_plugins()
# 3) ramps advance the inputs they drive
self._advance_ramps(dt)
# blocking states first
if self._pending_assert is not None:
if self._matcher.evaluate(self._pending_assert["conditions"]):
self._record(
self._pending_assert["label"], Verdict.PASS,
f"matched at {self.sim_time_ms:.0f} ms",
)
self._pending_assert = None
self._assert_deadline_ms = None
elif self.sim_time_ms >= self._assert_deadline_ms:
self._record(
self._pending_assert["label"], Verdict.FAIL,
f"timeout after {self._pending_assert['within_ms']} ms",
)
self._finish(Verdict.FAIL)
return self._result
else:
return None # still waiting for the assertion
elif self._wait_until_ms is not None:
if self.sim_time_ms < self._wait_until_ms:
return None
self._wait_until_ms = None
# drain immediate steps until the queue blocks or empties
while not self._finished:
if not self._step_queue:
self._finish(Verdict.PASS)
break
blocked = self._run_next_step()
if blocked:
break
return None if not self._finished else self._result
def _run_tick_plugins(self) -> None:
"""Invoke plant-model plugins registered under `plugins:`."""
for ref in self.scenario.get("plugins", []) or []:
fn_name = ref.rpartition(".")[2]
fn = self._plugin_actions.get(fn_name)
if fn is not None:
fn(self, None)
def _run_next_step(self) -> bool:
"""Execute one queued step.
Returns True when the step blocks (wait/assert) and the engine
must wait for a later tick; False to continue draining.
"""
step = self._step_queue.popleft()
if not isinstance(step, dict):
raise ScenarioError(f"sequence step must be a mapping: {step!r}")
blocked = False
for key, value in step.items():
if key == "set":
for alias_name, v in value.items():
self.set_alias(alias_name, v)
elif key == "reset":
for alias_name in value:
kind = self._aliases[alias_name].kind
self.set_alias(alias_name, 0 if kind == "ai" else False)
elif key == "toggle":
for alias_name in value:
cur = self.alias_value(alias_name)
self.set_alias(alias_name, not cur)
elif key == "pulse":
for alias_name, duration in value.items():
self.set_alias(alias_name, True)
self._schedule_pulse_off(
alias_name, _parse_duration(duration)
)
elif key == "ramp":
self._apply_ramp(value)
elif key == "wait":
self._wait_until_ms = self.sim_time_ms + _parse_duration(value)
blocked = True
elif key == "assert":
passed_now = self._begin_assert(value)
blocked = not passed_now
elif key == "macro":
self._expand_macro(value)
elif key == "wait_edge":
self._pending_edge = value
blocked = True
elif key == "report":
self._finish(
Verdict.PASS if str(value).upper() == "PASS" else Verdict.FAIL
)
elif key in self._plugin_actions:
self._plugin_actions[key](self, value)
else:
raise ScenarioError(f"unknown sequence step: {key!r}")
return blocked
def _begin_assert(self, value: object) -> bool:
"""Arm an assertion. Returns True if it passed immediately."""
if isinstance(value, dict):
spec = dict(value) # copy: we pop keys
label = str(spec.pop("label", ""))
within = _parse_duration(spec.pop("within", 0))
conditions = spec.pop("conditions", spec) or {}
else:
label, within, conditions = "assert", 0, value
label = label or "assert"
# condition already satisfied? pass without blocking
if self._matcher.evaluate(conditions):
self._record(label, Verdict.PASS,
f"matched at {self.sim_time_ms:.0f} ms")
return True
# arm for later ticks; a within of 0 still allows one tick for
# the plant/PLC to react before the verdict
self._pending_assert = {
"label": label,
"conditions": conditions,
"within_ms": within,
}
self._assert_deadline_ms = self.sim_time_ms + max(within, self.TICK_MS)
return False
def _apply_ramp(self, value: dict) -> None:
# forms: {name: x, to: y, over: '30s'} or {'level_sensor': {to: , over: }}
if "name" in value:
name = str(value["name"])
to = float(value["to"])
over = _parse_duration(value.get("over", 0))
else:
name, spec = next(iter(value.items()))
to = float(spec["to"])
over = _parse_duration(spec.get("over", 0))
self.start_ramp(name, to, over)
def _schedule_pulse_off(self, alias_name: str, duration_ms: float) -> None:
"""Implement pulse: alias ON now, OFF after duration_ms (sim time)."""
self._ramps.append(
{
"name": alias_name,
"from": 1,
"to": 0,
"over_ms": duration_ms,
"start_ms": self.sim_time_ms,
"alias": self._aliases[alias_name],
"_mode": "pulse",
}
)
def _expand_macro(self, ref: str | dict) -> None:
"""Expand a macro inline into the step queue (waits inside macros
block exactly like sequence waits)."""
if isinstance(ref, dict):
name = str(ref.get("name"))
args = ref.get("args", {}) or {}
else:
name, args = str(ref), {}
steps = self._macros.get(name)
if steps is None:
raise ScenarioError(f"unknown macro: {name!r}")
expanded: deque = deque()
for step in steps:
if not isinstance(step, dict):
raise ScenarioError(f"macro step must be a mapping: {step!r}")
expanded.append(
{
_interpolate(k, args): _interpolate_tree(v, args)
for k, v in step.items()
}
)
# insert right after the current position: remaining steps follow
rest = list(self._step_queue)
self._step_queue.clear()
self._step_queue.extend(expanded)
self._step_queue.extend(rest)
# -- verdict handling ------------------------------------------------------------
def _record(self, label: str, verdict: Verdict, detail: str = "") -> None:
self._result.steps.append(
StepResult(label, verdict, detail, self.sim_time_ms)
)
def _finish(self, verdict: Verdict) -> None:
self._finished = True
self._result.verdict = verdict
self._result.duration_ms = self.sim_time_ms
@property
def result(self) -> ScenarioResult:
return self._result
@property
def finished(self) -> bool:
return self._finished
class ScenarioError(ValueError):
"""Invalid scenario definition or runtime misuse."""
def _parse_duration(value: str | int | float) -> float:
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().lower()
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
for suffix, mult in multipliers.items():
if text.endswith(suffix):
return float(text[: -len(suffix)]) * mult
return float(text) # default: milliseconds
def _interpolate(name: str, args: dict) -> str:
if name.startswith("$"):
return str(args[name[1:]])
return name
def _interpolate_value(value: object, args: dict) -> object:
if isinstance(value, str) and value.startswith("$"):
return args[value[1:]]
return value
def _interpolate_tree(value: object, args: dict) -> object:
"""Recursively substitute $-references in nested step payloads."""
if isinstance(value, str):
return _interpolate_value(value, args)
if isinstance(value, dict):
return {
_interpolate(k, args) if isinstance(k, str) else k: _interpolate_tree(v, args)
for k, v in value.items()
}
if isinstance(value, list):
return [_interpolate_tree(v, args) for v in value]
return value
+63
View File
@@ -0,0 +1,63 @@
"""FAT report generation: verdict summary, JSONL step log, CSV."""
from __future__ import annotations
import csv
import json
from pathlib import Path
from .engine import ScenarioResult, Verdict
def result_summary(result: ScenarioResult) -> str:
lines = [
f"Scenario: {result.name}",
f"Verdict: {result.verdict.value}",
f"Duration: {result.duration_ms:.0f} ms (sim time)",
f"Steps: {len(result.steps)}",
]
for i, step in enumerate(result.steps, 1):
lines.append(f" {i:3d}. [{step.verdict.value}] {step.step}"
f" @ {step.sim_time_ms:.0f} ms — {step.detail}")
return "\n".join(lines)
def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
path = Path(path)
with path.open("w", encoding="utf-8") as fh:
fh.write(json.dumps({
"type": "scenario",
"name": result.name,
"verdict": result.verdict.value,
"duration_ms": result.duration_ms,
}) + "\n")
for step in result.steps:
fh.write(json.dumps({
"type": "step",
"step": step.step,
"verdict": step.verdict.value,
"detail": step.detail,
"sim_time_ms": step.sim_time_ms,
}) + "\n")
def write_csv(result: ScenarioResult, path: str | Path) -> None:
path = Path(path)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["scenario", "verdict", "duration_ms"])
writer.writerow([result.name, result.verdict.value, result.duration_ms])
writer.writerow([])
writer.writerow(["step", "verdict", "detail", "sim_time_ms"])
for step in result.steps:
writer.writerow([step.step, step.verdict.value, step.detail,
step.sim_time_ms])
def verdict_badge(verdict: Verdict) -> str:
return {
Verdict.PASS: "PASS",
Verdict.FAIL: "FAIL",
Verdict.ERROR: "ERROR",
Verdict.PENDING: "PENDING",
}[verdict]
+431
View File
@@ -0,0 +1,431 @@
"""Asynchronous Modbus TCP server for the WAGO simulator.
Accepts up to 15 concurrent connections (750-362 limit), parses MBAP
frames, decodes PDUs, dispatches against the datastore + register bank,
and applies watchdog/statistics behavior.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Callable
from . import pdu as pdu_mod
from .datastore import WagoDatastore
from .mbap import MBAP_HEADER_SIZE, MbapFrameReader, MbapError, build_frame
from .pdu import (
ExceptionResponse,
IllegalDataValue,
PduError,
ReadRequest,
ReadWriteMultipleRegistersRequest,
UnsupportedFunction,
WriteMultipleCoilsRequest,
WriteMultipleRegistersRequest,
WriteSingleCoilRequest,
WriteSingleRegisterRequest,
MaskWriteRegisterRequest,
GetCommEventCountRequest,
EXC_ILLEGAL_FUNCTION,
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
EXC_SLAVE_DEVICE_FAILURE,
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,
)
from .stats import ModbusStatistics
from .watchdog import ModbusWatchdog, RegisterWriteRefused
log = logging.getLogger("wago_sim.server")
MAX_CONNECTIONS = 15 # 750-362: 15 concurrent Modbus TCP connections
IDLE_CHECK_INTERVAL_S = 0.5
WATCHDOG_TICK_S = 0.1 # watchdog time base is 100 ms (0x1000 unit)
class WagoSimServer:
"""Modbus TCP server bound to a datastore and register bank."""
def __init__(
self,
datastore: WagoDatastore,
statistics: ModbusStatistics,
watchdog: ModbusWatchdog,
*,
host: str = "0.0.0.0",
port: int = 502,
response_delay_provider: Callable[[], int] | None = None,
idle_timeout_provider: Callable[[], int] | None = None,
) -> None:
self.datastore = datastore
self.stats = statistics
self.watchdog = watchdog
self.host = host
self.port = port
self._response_delay_provider = response_delay_provider or (lambda: 0)
self._idle_timeout_provider = idle_timeout_provider or (lambda: 0)
self._server: asyncio.Server | None = None
self._connections: set[asyncio.StreamWriter] = set()
self._last_activity: dict[int, float] = {} # id(writer) -> loop time
self._event_counter = 0
self._watchdog_task: asyncio.Task | None = None
@property
def connection_count(self) -> int:
return len(self._connections)
async def start(self) -> None:
self._server = await asyncio.start_server(
self._handle_connection, self.host, self.port
)
log.info("Modbus TCP server listening on %s:%d", self.host, self.port)
self._watchdog_task = asyncio.create_task(self._watchdog_ticker())
asyncio.create_task(self._idle_reaper())
async def stop(self) -> None:
if self._server is not None:
self._server.close()
await self._server.wait_closed()
self._server = None
for writer in list(self._connections):
await self._close(writer)
async def _watchdog_ticker(self) -> None:
"""Advance the Modbus watchdog every 100 ms of wall time."""
while self._server is not None:
await asyncio.sleep(WATCHDOG_TICK_S)
self.watchdog.tick()
async def serve_forever(self) -> None:
if self._server is None:
await self.start()
assert self._server is not None
async with self._server:
await self._server.serve_forever()
# -- connection handling ------------------------------------------------------
async def _handle_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
if len(self._connections) >= MAX_CONNECTIONS:
log.info("refusing connection (limit %d reached)", MAX_CONNECTIONS)
writer.close()
return
peer = writer.get_extra_info("peername")
self._connections.add(writer)
self._last_activity[id(writer)] = asyncio.get_running_loop().time()
log.info("connection from %s (open: %d)", peer, len(self._connections))
frame_reader = MbapFrameReader()
try:
while True:
try:
chunk = await reader.read(4096)
except (ConnectionError, OSError):
break
if not chunk:
break
try:
frame_reader.feed(chunk)
except MbapError as exc:
self.stats.bad_length += 1
log.warning("dropping malformed stream data: %s", exc)
break
while True:
try:
pair = frame_reader.pop_frame()
except MbapError as exc:
self.stats.bad_protocol += 1
log.warning("bad MBAP: %s", exc)
break
if pair is None:
break
header, request_pdu = pair
response = await self._process(header, request_pdu, writer)
if response is None:
continue # e.g. connection closed by watchdog
writer.write(response)
await writer.drain()
finally:
await self._close(writer)
async def _close(self, writer: asyncio.StreamWriter) -> None:
self._connections.discard(writer)
self._last_activity.pop(id(writer), None)
with contextlib.suppress(ConnectionError, OSError):
writer.close()
await writer.wait_closed()
async def close_all_connections(self) -> None:
"""Watchdog 0x1009=1: close TCP connections on timeout."""
for writer in list(self._connections):
log.info("closing connection per watchdog policy")
await self._close(writer)
async def _idle_reaper(self) -> None:
while self._server is not None:
await asyncio.sleep(IDLE_CHECK_INTERVAL_S)
timeout_ms = self._idle_timeout_provider() or 0
if timeout_ms <= 0:
continue
now = asyncio.get_running_loop().time()
for writer in list(self._connections):
last = self._last_activity.get(id(writer))
if last is not None and (now - last) * 1000 > timeout_ms:
log.info("closing idle connection (timeout %d ms)", timeout_ms)
await self._close(writer)
def _touch(self, writer: asyncio.StreamWriter) -> None:
self._last_activity[id(writer)] = asyncio.get_running_loop().time()
# -- request processing ---------------------------------------------------------
async def _process(
self, header, request_pdu: bytes, writer: asyncio.StreamWriter
) -> bytes | None:
self._touch(writer)
self.stats.mod_tcp_message_counter += 1
try:
request = pdu_mod.decode_request(request_pdu)
except UnsupportedFunction as exc:
self.stats.bad_function += 1
log.debug("FC%#04x unsupported: %s", exc.function_code, exc)
response: pdu_mod.Response = ExceptionResponse(
exc.function_code, EXC_ILLEGAL_FUNCTION
)
return self._finish(header, request_pdu, response, writer)
except IllegalDataValue as exc:
self.stats.bad_data += 1
response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
)
return self._finish(header, request_pdu, response, writer)
except PduError as exc:
self.stats.bad_length += 1
log.warning("malformed PDU: %s", exc)
response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
)
return self._finish(header, request_pdu, response, writer)
# watchdog monitors configured function codes
self.watchdog.on_function_code(request.function_code)
# Standard-mode lock after watchdog expiry: process-data access is
# answered with 0x04, while watchdog/config registers stay
# accessible (manual §11.2.5.2, Behavior 2)
if self.watchdog.process_data_blocked and _targets_process_data(request):
self.stats.slave_device_failure += 1
response = ExceptionResponse(
request.function_code, EXC_SLAVE_DEVICE_FAILURE
)
return self._finish(header, request_pdu, response, writer)
try:
response = self._dispatch(request)
except RegisterWriteRefused:
response = ExceptionResponse(
request.function_code, EXC_ILLEGAL_DATA_VALUE
)
except Exception:
log.exception("internal error while processing %r", request)
self.stats.slave_device_failure += 1
response = ExceptionResponse(
request.function_code, EXC_SLAVE_DEVICE_FAILURE
)
return self._finish(header, request_pdu, response, writer)
def _dispatch(self, request: pdu_mod.Request) -> pdu_mod.Response:
if isinstance(request, ReadRequest):
return self._dispatch_read(request)
if isinstance(request, WriteSingleCoilRequest):
result = self.datastore.write_bits(
request.address, [request.value]
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteSingleCoilResponse(request.address, request.value)
if isinstance(request, WriteSingleRegisterRequest):
result = self.datastore.write_words(request.address, [request.value])
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteSingleRegisterResponse(
request.address, request.value
)
if isinstance(request, WriteMultipleCoilsRequest):
result = self.datastore.write_bits(
request.address, list(request.values)
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteMultipleResponse(
request.function_code, request.address, len(request.values)
)
if isinstance(request, WriteMultipleRegistersRequest):
result = self.datastore.write_words(
request.address, list(request.values)
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteMultipleResponse(
request.function_code, request.address, len(request.values)
)
if isinstance(request, MaskWriteRegisterRequest):
result = self.datastore.mask_write_word(
request.address, request.and_mask, request.or_mask
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.MaskWriteRegisterResponse(
request.address, request.and_mask, request.or_mask
)
if isinstance(request, ReadWriteMultipleRegistersRequest):
write_result = self.datastore.write_words(
request.write_address, list(request.values)
)
if isinstance(write_result, ExceptionResponse):
self.stats.bad_address += 1
return write_result
read = self.datastore.read_words(request.read_address, request.read_count)
if isinstance(read, ExceptionResponse):
self.stats.bad_address += 1
return read
resp = pdu_mod.ReadResponse(request.function_code)
resp.registers = read
return resp
if isinstance(request, GetCommEventCountRequest):
self._event_counter += 1
return pdu_mod.GetCommEventCountResponse(0, self._event_counter)
raise AssertionError(f"unhandled request type: {request!r}")
def _dispatch_read(self, request: ReadRequest) -> pdu_mod.Response:
fc = request.function_code
if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS):
if request.count > pdu_mod.MAX_READ_BITS:
self.stats.too_many_bits += 1
return ExceptionResponse(fc, EXC_ILLEGAL_DATA_VALUE)
bits = self.datastore.read_bits(request.address, request.count)
if isinstance(bits, ExceptionResponse):
self.stats.bad_address += 1
return bits
resp = pdu_mod.ReadResponse(fc)
resp.bits = bits
return resp
if fc in (FC_READ_HOLDING_REGISTERS, FC_READ_INPUT_REGISTERS):
if request.count > pdu_mod.MAX_READ_REGISTERS:
self.stats.too_many_registers += 1
return ExceptionResponse(fc, EXC_ILLEGAL_DATA_VALUE)
words = self.datastore.read_words(request.address, request.count)
if isinstance(words, ExceptionResponse):
self.stats.bad_address += 1
return words
resp = pdu_mod.ReadResponse(fc)
resp.registers = words
return resp
raise AssertionError(f"unexpected read FC: {fc:#04x}")
def _finish(
self,
header,
request_pdu: bytes,
response: pdu_mod.Response,
writer: asyncio.StreamWriter,
) -> bytes | None:
encoded = response.encode()
delay_ms = self._response_delay_provider()
if delay_ms:
# register 0x1037: delay the response (fire-and-forget task
# keeps the pipeline non-blocking for other connections)
async def send() -> None:
await asyncio.sleep(delay_ms / 1000)
writer.write(build_frame(header.transaction_id, header.unit_id, encoded))
await writer.drain()
asyncio.create_task(send())
return None
writer.write(build_frame(header.transaction_id, header.unit_id, encoded))
return b"" # caller must not write again
@staticmethod
def _is_process_data_fc(fc: int) -> bool:
return fc in (
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
)
def _targets_process_data(request: pdu_mod.Request) -> bool:
"""Whether the request addresses process data (vs. config registers).
Word process areas: 0x0000-0x02FF, 0x6000-0x62FB, 0x7000-0x72FB.
Bit process areas: 0x0000-0x03FF, 0x8000-0x85F7, 0x9000-0x95F7.
Configuration registers (0x1000-0x2FFF) stay accessible after a
watchdog timeout in Standard mode (manual §11.2.5.2).
"""
if isinstance(request, ReadRequest | WriteSingleCoilRequest
| WriteSingleRegisterRequest | MaskWriteRegisterRequest):
return _is_process_address(request.address)
if isinstance(request, WriteMultipleCoilsRequest | WriteMultipleRegistersRequest):
return _is_process_address(request.address)
if isinstance(request, ReadWriteMultipleRegistersRequest):
return (_is_process_address(request.read_address)
or _is_process_address(request.write_address))
# GetCommEventCount has no address
return False
def _is_process_address(address: int) -> bool:
"""Address inside the word/bit process-image areas (not config regs)."""
word = (
0x0000 <= address <= 0x02FF
or 0x6000 <= address <= 0x62FB
or 0x7000 <= address <= 0x72FB
)
bit = (
0x0000 <= address <= 0x03FF
or 0x8000 <= address <= 0x85F7
or 0x9000 <= address <= 0x95F7
)
return word or bit
def _is_process_data_fc(fc: int) -> bool:
return fc in (
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
) or fc == FC_MASK_WRITE_REGISTER
def is_process_data_fc(fc: int) -> bool:
"""Public alias for the scenario engine."""
return _is_process_data_fc(fc)
+68
View File
@@ -0,0 +1,68 @@
"""Statistics counters for the Modbus TCP interface (register 0x1029).
Mirrors the 750-362 statistics block (manual §11.2.5.5, Table 143):
Word 1 SlaveDeviceFailure local bus error, fieldbus error via watchdog
Word 2 BadProtocol error in the MODBUS TCP header
Word 3 BadLength wrong telegram length
Word 4 BadFunction invalid function code
Word 5 BadAddress invalid register address
Word 6 BadData invalid value
Word 7 TooManyRegisters register count too large (R/W 125/100)
Word 8 TooManyBits coil count too large (R/W 2000/800)
Word 9 ModTcpMessageCounter received MODBUS/TCP requests
Writing 0xAA55 or 0x55AA to 0x1029 resets all counters.
"""
from __future__ import annotations
from dataclasses import dataclass, field
STATS_REGISTER = 0x1029
STATS_WORD_COUNT = 9
RESET_KEYS = (0xAA55, 0x55AA)
@dataclass
class ModbusStatistics:
slave_device_failure: int = 0
bad_protocol: int = 0
bad_length: int = 0
bad_function: int = 0
bad_address: int = 0
bad_data: int = 0
too_many_registers: int = 0
too_many_bits: int = 0
mod_tcp_message_counter: int = 0
def as_words(self) -> list[int]:
return [
self.slave_device_failure,
self.bad_protocol,
self.bad_length,
self.bad_function,
self.bad_address,
self.bad_data,
self.too_many_registers,
self.too_many_bits,
self.mod_tcp_message_counter,
]
def reset(self) -> None:
self.slave_device_failure = 0
self.bad_protocol = 0
self.bad_length = 0
self.bad_function = 0
self.bad_address = 0
self.bad_data = 0
self.too_many_registers = 0
self.too_many_bits = 0
self.mod_tcp_message_counter = 0
def handle_reset_write(self, value: int) -> bool:
"""Returns True when `value` is a reset key and counters were reset."""
if value in RESET_KEYS:
self.reset()
return True
return False
+251
View File
@@ -0,0 +1,251 @@
"""Modbus watchdog simulation (registers 0x1000-0x100B).
Behavior per 750-362 manual §11.2.5.2/3:
- 0x1000: timeout value, unit 100 ms, default 0x0064 (100 units = 10 s).
Writable only while the watchdog is inactive.
- 0x1001: coding mask for FC1..FC16 (bit n-1 selects FC n), default 0xFFFF.
- 0x1002: coding mask FC17..FC32 (default 0xFFFF; FC17+ unsupported).
- 0x1003: alternative trigger — write nonzero to start; consecutive writes
must differ in value to re-trigger (kick).
- 0x1004: minimum trigger time (read-only, returns 0xFFFF).
- 0x1005: stop sequence — write 0xAAAA first, then 0x5555.
- 0x1006: status — 0 inactive, 1 active, 2 expired.
- 0x1007: restart — write 1 after expiry.
- 0x1008: simple stop — write 0xAA55 or 0x55AA.
- 0x1009: close TCP socket on timeout (0/1).
- 0x100A: type — 0 Standard, 1 Alternative.
- 0x100B: write 0x55AA/0xAA55 to make parameters remanent.
On timeout the outputs are cleared (analog 0 / digital OFF). In Standard
mode, subsequent process-data access returns exception 0x04 (Slave Device
Failure) while watchdog registers stay accessible. In Alternative mode the
watchdog restarts with the next monitored function code.
"""
from __future__ import annotations
import enum
from collections.abc import Callable
WD_TIME = 0x1000
WD_MASK_1_16 = 0x1001
WD_MASK_17_32 = 0x1002
WD_TRIGGER = 0x1003
WD_MIN_TRIGGER_TIME = 0x1004
WD_STOP_MASK = 0x1005
WD_STATUS = 0x1006
WD_RESTART = 0x1007
WD_SIMPLE_STOP = 0x1008
WD_CLOSE_ON_TIMEOUT = 0x1009
WD_TYPE = 0x100A
WD_SAVE_PARAMS = 0x100B
SAVE_KEYS = (0x55AA, 0xAA55)
SIMPLE_STOP_KEYS = (0xAA55, 0x55AA)
WD_STATUS_INACTIVE = 0
WD_STATUS_ACTIVE = 1
WD_STATUS_EXPIRED = 2
class WatchdogType(enum.Enum):
STANDARD = 0
ALTERNATIVE = 1
class ModbusWatchdog:
"""State machine for the Modbus watchdog registers.
The simulator calls :meth:`tick` every 100 ms of simulated time and
:meth:`on_function_code` for every processed request; the datastore is
cleared through the ``clear_outputs`` callback on expiry.
"""
def __init__(
self,
*,
on_expire: Callable[[], None],
on_connections_close: Callable[[], None] | None = None,
) -> None:
self._time_units = 0x0064 # 0x1000, unit 100 ms
self._mask_1_16 = 0xFFFF # 0x1001
self._mask_17_32 = 0xFFFF # 0x1002
self._status = WD_STATUS_INACTIVE # 0x1006
self._close_on_timeout = 0 # 0x1009
self._type = WatchdogType.STANDARD # 0x100A
self._elapsed_units = 0
self._last_trigger_value: int | None = None
self._stop_mask_state = False # saw 0xAAAA, awaiting 0x5555
self._on_expire = on_expire
self._on_connections_close = on_connections_close
# Standard-mode process data lock after expiry
self._process_data_blocked = False
# -- register interface -----------------------------------------------------
def read(self, address: int) -> int:
if address == WD_TIME:
return self._time_units
if address == WD_MASK_1_16:
return self._mask_1_16
if address == WD_MASK_17_32:
return self._mask_17_32
if address == WD_TRIGGER:
return self._last_trigger_value or 0
if address == WD_MIN_TRIGGER_TIME:
return 0xFFFF
if address == WD_STATUS:
status = self._status
if status == WD_STATUS_EXPIRED and self._type is WatchdogType.ALTERNATIVE:
# reading 0x1006 returns "expired" once; the read itself
# restarts the watchdog, so the next read shows "active"
# (manual §11.2.5.2, Behavior 1)
self._restart()
return status
if address == WD_RESTART:
return 0
if address == WD_SIMPLE_STOP:
return 0
if address == WD_CLOSE_ON_TIMEOUT:
return self._close_on_timeout
if address == WD_TYPE:
return self._type.value
if address == WD_SAVE_PARAMS:
return 0
raise KeyError(f"not a watchdog register: {address:#06x}")
def write(self, address: int, value: int) -> None:
value &= 0xFFFF
if address == WD_TIME:
if self._status != WD_STATUS_INACTIVE:
raise RegisterWriteRefused("cannot change time while running")
self._time_units = value
return
if address in (WD_MASK_1_16, WD_MASK_17_32):
if self._status != WD_STATUS_INACTIVE:
raise RegisterWriteRefused("cannot change mask while running")
if address == WD_MASK_1_16:
self._mask_1_16 = value
else:
self._mask_17_32 = value
return
if address == WD_TRIGGER:
if self._status == WD_STATUS_EXPIRED:
# a differing value resets the fault and restarts
if value != self._last_trigger_value:
self._restart()
self._last_trigger_value = value
return
if self._status == WD_STATUS_INACTIVE:
# after power-on, only a nonzero value starts the watchdog
if value != 0:
self._start()
self._last_trigger_value = value
return
# active: kick only when the value differs from the last one
# (manual example uses alternating counter values 0x0000, 0x0001,
# so zero IS a valid kick value once running)
if value != self._last_trigger_value:
self._kick()
self._last_trigger_value = value
return
if address == WD_STOP_MASK:
# sequence 0xAAAA then 0x5555
if value == 0xAAAA:
self._stop_mask_state = True
return
if value == 0x5555 and self._stop_mask_state:
self._stop_mask_state = False
self._stop()
return
self._stop_mask_state = False
return
if address == WD_RESTART:
if value == 0x0001:
self._restart()
return
if address == WD_SIMPLE_STOP:
if value in SIMPLE_STOP_KEYS:
self._stop()
self._process_data_blocked = False
return
if address == WD_CLOSE_ON_TIMEOUT:
self._close_on_timeout = 1 if value else 0
return
if address == WD_TYPE:
self._type = WatchdogType.ALTERNATIVE if value else WatchdogType.STANDARD
self._mask_1_16 = 0xFFFF # switching types resets the mask
return
if address == WD_SAVE_PARAMS:
if value in SAVE_KEYS:
# remanent storage is a no-op in the simulator
return
raise RegisterWriteRefused("invalid save key")
raise KeyError(f"not a watchdog register: {address:#06x}")
# -- runtime ------------------------------------------------------------------
@property
def status(self) -> int:
return self._status
@property
def process_data_blocked(self) -> bool:
"""Standard mode: process data access answered with 0x04 after expiry."""
return self._process_data_blocked
@property
def close_connections_on_timeout(self) -> bool:
return bool(self._close_on_timeout)
def on_function_code(self, fc: int) -> None:
"""Called for every processed request; kicks the watchdog when the
coding mask selects the function code."""
if self._status != WD_STATUS_ACTIVE:
return
if fc <= 16:
if self._mask_1_16 & (1 << (fc - 1)):
self._kick()
elif fc <= 32:
if self._mask_17_32 & (1 << (fc - 17)):
self._kick()
def tick(self) -> None:
"""Advance simulated time by 100 ms."""
if self._status != WD_STATUS_ACTIVE:
return
self._elapsed_units += 1
if self._elapsed_units >= self._time_units:
self._expire()
# -- internals -----------------------------------------------------------------
def _start(self) -> None:
self._status = WD_STATUS_ACTIVE
self._elapsed_units = 0
def _kick(self) -> None:
self._elapsed_units = 0
def _stop(self) -> None:
self._status = WD_STATUS_INACTIVE
self._elapsed_units = 0
self._process_data_blocked = False
def _restart(self) -> None:
self._status = WD_STATUS_ACTIVE
self._elapsed_units = 0
self._process_data_blocked = False
def _expire(self) -> None:
self._status = WD_STATUS_EXPIRED
if self._type is WatchdogType.STANDARD:
self._process_data_blocked = True
self._on_expire()
if self._close_on_timeout and self._on_connections_close:
self._on_connections_close()
class RegisterWriteRefused(Exception):
"""Write to a register rejected by device semantics (exception 0x03)."""