🎉 Initial commit
This commit is contained in:
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)."""
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Scenario package: YAML DSL model, engine, actions, assertions, reporting."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)."""
|
||||
Reference in New Issue
Block a user