420 lines
17 KiB
Python
420 lines
17 KiB
Python
"""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()
|
|
|
|
def reset_images(self) -> None:
|
|
"""Reconfiguration (§11): zero both images before a layout rebuild.
|
|
|
|
The arrays keep their full 750-362 geometry (256+764 words per
|
|
direction); the VirtualNode re-applies the digital word offsets
|
|
and the module layout on top.
|
|
"""
|
|
self._in_words = [0] * (AREA1_INPUT_WORDS + AREA2_INPUT_WORDS)
|
|
self._out_words = [0] * (AREA1_OUTPUT_WORDS + AREA2_OUTPUT_WORDS)
|
|
self._digital_in_word_offset = 0
|
|
self._digital_out_word_offset = 0
|
|
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}") |