venv added, updated

This commit is contained in:
Norbert
2024-09-13 09:46:28 +02:00
parent 577596d9f3
commit 82af8c809a
4812 changed files with 640223 additions and 2 deletions

View File

@@ -0,0 +1,21 @@
"""Datastore."""
__all__ = [
"ModbusBaseSlaveContext",
"ModbusSequentialDataBlock",
"ModbusSparseDataBlock",
"ModbusSlaveContext",
"ModbusServerContext",
"ModbusSimulatorContext",
]
from pymodbus.datastore.context import (
ModbusBaseSlaveContext,
ModbusServerContext,
ModbusSlaveContext,
)
from pymodbus.datastore.simulator import ModbusSimulatorContext
from pymodbus.datastore.store import (
ModbusSequentialDataBlock,
ModbusSparseDataBlock,
)

View File

@@ -0,0 +1,250 @@
"""Context for datastore."""
from __future__ import annotations
# pylint: disable=missing-type-doc
from pymodbus.datastore.store import ModbusSequentialDataBlock
from pymodbus.exceptions import NoSuchSlaveException
from pymodbus.logging import Log
class ModbusBaseSlaveContext:
"""Interface for a modbus slave data context.
Derived classes must implemented the following methods:
reset(self)
validate(self, fx, address, count=1)
getValues/async_getValues(self, fc_as_hex, address, count=1)
setValues/async_setValues(self, fc_as_hex, address, values)
"""
_fx_mapper = {2: "d", 4: "i"}
_fx_mapper.update([(i, "h") for i in (3, 6, 16, 22, 23)])
_fx_mapper.update([(i, "c") for i in (1, 5, 15)])
def decode(self, fx):
"""Convert the function code to the datastore to.
:param fx: The function we are working with
:returns: one of [d(iscretes),i(nputs),h(olding),c(oils)
"""
return self._fx_mapper[fx]
async def async_getValues(self, fc_as_hex: int, address: int, count: int = 1) -> list[int | bool | None]:
"""Get `count` values from datastore.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
return self.getValues(fc_as_hex, address, count)
async def async_setValues(self, fc_as_hex: int, address: int, values: list[int | bool]) -> None:
"""Set the datastore with the supplied values.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param values: The new values to be set
"""
self.setValues(fc_as_hex, address, values)
def getValues(self, fc_as_hex: int, address: int, count: int = 1) -> list[int | bool | None]:
"""Get `count` values from datastore.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
Log.error("getValues({},{},{}) not implemented!", fc_as_hex, address, count)
return []
def setValues(self, fc_as_hex: int, address: int, values: list[int | bool]) -> None:
"""Set the datastore with the supplied values.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param values: The new values to be set
"""
# ---------------------------------------------------------------------------#
# Slave Contexts
# ---------------------------------------------------------------------------#
class ModbusSlaveContext(ModbusBaseSlaveContext):
"""Create a modbus data model with data stored in a block.
:param di: discrete inputs initializer ModbusDataBlock
:param co: coils initializer ModbusDataBlock
:param hr: holding register initializer ModbusDataBlock
:param ir: input registers initializer ModbusDataBlock
:param zero_mode: Not add one to address
When True, a request for address zero to n will map to
datastore address zero to n.
When False, a request for address zero to n will map to
datastore address one to n+1, based on section 4.4 of
specification.
Default is False.
"""
def __init__(self, *_args,
di=ModbusSequentialDataBlock.create(),
co=ModbusSequentialDataBlock.create(),
ir=ModbusSequentialDataBlock.create(),
hr=ModbusSequentialDataBlock.create(),
zero_mode=False):
"""Initialize the datastores."""
self.store = {}
self.store["d"] = di
self.store["c"] = co
self.store["i"] = ir
self.store["h"] = hr
self.zero_mode = zero_mode
def __str__(self):
"""Return a string representation of the context.
:returns: A string representation of the context
"""
return "Modbus Slave Context"
def reset(self):
"""Reset all the datastores to their default values."""
for datastore in iter(self.store.values()):
datastore.reset()
def validate(self, fc_as_hex, address, count=1):
"""Validate the request to make sure it is in range.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param count: The number of values to test
:returns: True if the request in within range, False otherwise
"""
if not self.zero_mode:
address += 1
Log.debug("validate: fc-[{}] address-{}: count-{}", fc_as_hex, address, count)
return self.store[self.decode(fc_as_hex)].validate(address, count)
def getValues(self, fc_as_hex, address, count=1):
"""Get `count` values from datastore.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
if not self.zero_mode:
address += 1
Log.debug("getValues: fc-[{}] address-{}: count-{}", fc_as_hex, address, count)
return self.store[self.decode(fc_as_hex)].getValues(address, count)
def setValues(self, fc_as_hex, address, values):
"""Set the datastore with the supplied values.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param values: The new values to be set
"""
if not self.zero_mode:
address += 1
Log.debug("setValues[{}] address-{}: count-{}", fc_as_hex, address, len(values))
self.store[self.decode(fc_as_hex)].setValues(address, values)
def register(self, function_code, fc_as_hex, datablock=None):
"""Register a datablock with the slave context.
:param function_code: function code (int)
:param fc_as_hex: string representation of function code (e.g "cf" )
:param datablock: datablock to associate with this function code
"""
self.store[fc_as_hex] = datablock or ModbusSequentialDataBlock.create()
self._fx_mapper[function_code] = fc_as_hex
class ModbusServerContext:
"""This represents a master collection of slave contexts.
If single is set to true, it will be treated as a single
context so every slave_id returns the same context. If single
is set to false, it will be interpreted as a collection of
slave contexts.
"""
def __init__(self, slaves=None, single=True):
"""Initialize a new instance of a modbus server context.
:param slaves: A dictionary of client contexts
:param single: Set to true to treat this as a single context
"""
self.single = single
self._slaves = slaves or {}
if self.single:
self._slaves = {0: self._slaves}
def __iter__(self):
"""Iterate over the current collection of slave contexts.
:returns: An iterator over the slave contexts
"""
return iter(self._slaves.items())
def __contains__(self, slave):
"""Check if the given slave is in this list.
:param slave: slave The slave to check for existence
:returns: True if the slave exists, False otherwise
"""
if self.single and self._slaves:
return True
return slave in self._slaves
def __setitem__(self, slave, context):
"""Use to set a new slave context.
:param slave: The slave context to set
:param context: The new context to set for this slave
:raises NoSuchSlaveException:
"""
if self.single:
slave = 0
if 0xF7 >= slave >= 0x00:
self._slaves[slave] = context
else:
raise NoSuchSlaveException(f"slave index :{slave} out of range")
def __delitem__(self, slave):
"""Use to access the slave context.
:param slave: The slave context to remove
:raises NoSuchSlaveException:
"""
if not self.single and (0xF7 >= slave >= 0x00):
del self._slaves[slave]
else:
raise NoSuchSlaveException(f"slave index: {slave} out of range")
def __getitem__(self, slave):
"""Use to get access to a slave context.
:param slave: The slave context to get
:returns: The requested slave context
:raises NoSuchSlaveException:
"""
if self.single:
slave = 0
if slave in self._slaves:
return self._slaves.get(slave)
raise NoSuchSlaveException(
f"slave - {slave} does not exist, or is out of range"
)
def slaves(self):
"""Define slaves."""
# Python3 now returns keys() as iterable
return list(self._slaves.keys())

View File

@@ -0,0 +1,129 @@
"""Remote datastore."""
from pymodbus.datastore import ModbusBaseSlaveContext
from pymodbus.exceptions import NotImplementedException
from pymodbus.logging import Log
# ---------------------------------------------------------------------------#
# Context
# ---------------------------------------------------------------------------#
class RemoteSlaveContext(ModbusBaseSlaveContext):
"""TODO.
This creates a modbus data model that connects to
a remote device (depending on the client used)
"""
def __init__(self, client, slave=None):
"""Initialize the datastores.
:param client: The client to retrieve values with
:param slave: Unit ID of the remote slave
"""
self._client = client
self.slave = slave
self.result = None
self.__build_mapping()
if not self.__set_callbacks:
Log.error("Init went wrong.")
def reset(self):
"""Reset all the datastores to their default values."""
raise NotImplementedException()
def validate(self, _fc_as_hex, _address, _count):
"""Validate the request to make sure it is in range.
:returns: True
"""
return True
def getValues(self, fc_as_hex, _address, _count=1):
"""Get values from real call in validate."""
if fc_as_hex in self._write_fc:
return [0]
group_fx = self.decode(fc_as_hex)
func_fc = self.__get_callbacks[group_fx]
self.result = func_fc(_address, _count)
return self.__extract_result(self.decode(fc_as_hex), self.result)
def setValues(self, fc_as_hex, address, values):
"""Set the datastore with the supplied values."""
group_fx = self.decode(fc_as_hex)
if fc_as_hex not in self._write_fc:
raise ValueError(f"setValues() called with an non-write function code {fc_as_hex}")
func_fc = self.__set_callbacks[f"{group_fx}{fc_as_hex}"]
if fc_as_hex in {0x0F, 0x10}: # Write Multiple Coils, Write Multiple Registers
self.result = func_fc(address, values)
else:
self.result = func_fc(address, values[0])
# if self.result.isError():
# return self.result
def __str__(self):
"""Return a string representation of the context.
:returns: A string representation of the context
"""
return f"Remote Slave Context({self._client})"
def __build_mapping(self):
"""Build the function code mapper."""
params = {}
if self.slave:
params["slave"] = self.slave
self.__get_callbacks = {
"d": lambda a, c: self._client.read_discrete_inputs(
a, c, **params
),
"c": lambda a, c: self._client.read_coils(
a, c, **params
),
"h": lambda a, c: self._client.read_holding_registers(
a, c, **params
),
"i": lambda a, c: self._client.read_input_registers(
a, c, **params
),
}
self.__set_callbacks = {
"d5": lambda a, v: self._client.write_coil(
a, v, **params
),
"d15": lambda a, v: self._client.write_coils(
a, v, **params
),
"c5": lambda a, v: self._client.write_coil(
a, v, **params
),
"c15": lambda a, v: self._client.write_coils(
a, v, **params
),
"h6": lambda a, v: self._client.write_register(
a, v, **params
),
"h16": lambda a, v: self._client.write_registers(
a, v, **params
),
"i6": lambda a, v: self._client.write_register(
a, v, **params
),
"i16": lambda a, v: self._client.write_registers(
a, v, **params
),
}
self._write_fc = (0x05, 0x06, 0x0F, 0x10)
def __extract_result(self, fc_as_hex, result):
"""Extract the values out of a response.
TODO make this consistent (values?)
"""
if not result.isError():
if fc_as_hex in {"d", "c"}:
return result.bits
if fc_as_hex in {"h", "i"}:
return result.registers
else:
return result
return None

View File

@@ -0,0 +1,803 @@
"""Pymodbus ModbusSimulatorContext."""
from __future__ import annotations
import dataclasses
import random
import struct
from collections.abc import Callable
from datetime import datetime
from typing import Any
from pymodbus.datastore.context import ModbusBaseSlaveContext
WORD_SIZE = 16
@dataclasses.dataclass(frozen=True)
class CellType:
"""Define single cell types."""
INVALID: int = 0
BITS: int = 1
UINT16: int = 2
UINT32: int = 3
FLOAT32: int = 4
STRING: int = 5
NEXT: int = 6
@dataclasses.dataclass(repr=False, eq=False)
class Cell:
"""Handle a single cell."""
type: int = CellType.INVALID
access: bool = False
value: int = 0
action: int = 0
action_parameters: dict[str, Any] | None = None
count_read: int = 0
count_write: int = 0
class TextCell: # pylint: disable=too-few-public-methods
"""A textual representation of a single cell."""
type: str
access: str
value: str
action: str
action_parameters: str
count_read: str
count_write: str
@dataclasses.dataclass
class Label: # pylint: disable=too-many-instance-attributes
"""Defines all dict values.
:meta private:
"""
action: str = "action"
addr: str = "addr"
any: str = "any"
co_size: str = "co size"
defaults: str = "defaults"
di_size: str = "di size"
hr_size: str = "hr size"
increment: str = "increment"
invalid: str = "invalid"
ir_size: str = "ir size"
parameters: str = "parameters"
method: str = "method"
next: str = "next"
none: str = "none"
random: str = "random"
repeat: str = "repeat"
reset: str = "reset"
setup: str = "setup"
shared_blocks: str = "shared blocks"
timestamp: str = "timestamp"
repeat_to: str = "to"
type: str = "type"
type_bits = "bits"
type_exception: str = "type exception"
type_uint16: str = "uint16"
type_uint32: str = "uint32"
type_float32: str = "float32"
type_string: str = "string"
uptime: str = "uptime"
value: str = "value"
write: str = "write"
@classmethod
def try_get(cls, key, config_part):
"""Check if entry is present in config."""
if key not in config_part:
txt = f"ERROR Configuration invalid, missing {key} in {config_part}"
raise RuntimeError(txt)
return config_part[key]
class Setup:
"""Setup simulator.
:meta private:
"""
def __init__(self, runtime):
"""Initialize."""
self.runtime = runtime
self.config = {}
self.config_types: dict[str, dict[str, Any]] = {
Label.type_bits: {
Label.type: CellType.BITS,
Label.next: None,
Label.value: 0,
Label.action: None,
Label.method: self.handle_type_bits,
},
Label.type_uint16: {
Label.type: CellType.UINT16,
Label.next: None,
Label.value: 0,
Label.action: None,
Label.method: self.handle_type_uint16,
},
Label.type_uint32: {
Label.type: CellType.UINT32,
Label.next: CellType.NEXT,
Label.value: 0,
Label.action: None,
Label.method: self.handle_type_uint32,
},
Label.type_float32: {
Label.type: CellType.FLOAT32,
Label.next: CellType.NEXT,
Label.value: 0,
Label.action: None,
Label.method: self.handle_type_float32,
},
Label.type_string: {
Label.type: CellType.STRING,
Label.next: CellType.NEXT,
Label.value: 0,
Label.action: None,
Label.method: self.handle_type_string,
},
}
def handle_type_bits(self, start, stop, value, action, action_parameters):
"""Handle type bits."""
for reg in self.runtime.registers[start:stop]:
if reg.type != CellType.INVALID:
raise RuntimeError(f'ERROR "{Label.type_bits}" {reg} used')
reg.value = value
reg.type = CellType.BITS
reg.action = action
reg.action_parameters = action_parameters
def handle_type_uint16(self, start, stop, value, action, action_parameters):
"""Handle type uint16."""
for reg in self.runtime.registers[start:stop]:
if reg.type != CellType.INVALID:
raise RuntimeError(f'ERROR "{Label.type_uint16}" {reg} used')
reg.value = value
reg.type = CellType.UINT16
reg.action = action
reg.action_parameters = action_parameters
def handle_type_uint32(self, start, stop, value, action, action_parameters):
"""Handle type uint32."""
regs_value = ModbusSimulatorContext.build_registers_from_value(value, True)
for i in range(start, stop, 2):
regs = self.runtime.registers[i : i + 2]
if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID:
raise RuntimeError(f'ERROR "{Label.type_uint32}" {i},{i + 1} used')
regs[0].value = regs_value[0]
regs[0].type = CellType.UINT32
regs[0].action = action
regs[0].action_parameters = action_parameters
regs[1].value = regs_value[1]
regs[1].type = CellType.NEXT
def handle_type_float32(self, start, stop, value, action, action_parameters):
"""Handle type uint32."""
regs_value = ModbusSimulatorContext.build_registers_from_value(value, False)
for i in range(start, stop, 2):
regs = self.runtime.registers[i : i + 2]
if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID:
raise RuntimeError(f'ERROR "{Label.type_float32}" {i},{i + 1} used')
regs[0].value = regs_value[0]
regs[0].type = CellType.FLOAT32
regs[0].action = action
regs[0].action_parameters = action_parameters
regs[1].value = regs_value[1]
regs[1].type = CellType.NEXT
def handle_type_string(self, start, stop, value, action, action_parameters):
"""Handle type string."""
regs = stop - start
reg_len = regs * 2
if len(value) > reg_len:
raise RuntimeError(
f'ERROR "{Label.type_string}" {start} too long "{value}"'
)
value = value.ljust(reg_len)
for i in range(stop - start):
reg = self.runtime.registers[start + i]
if reg.type != CellType.INVALID:
raise RuntimeError(f'ERROR "{Label.type_string}" {start + i} used')
j = i * 2
reg.value = int.from_bytes(bytes(value[j : j + 2], "UTF-8"), "big")
reg.type = CellType.NEXT
self.runtime.registers[start].type = CellType.STRING
self.runtime.registers[start].action = action
self.runtime.registers[start].action_parameters = action_parameters
def handle_setup_section(self):
"""Load setup section."""
layout = Label.try_get(Label.setup, self.config)
self.runtime.fc_offset = {key: 0 for key in range(25)}
size_co = Label.try_get(Label.co_size, layout)
size_di = Label.try_get(Label.di_size, layout)
size_hr = Label.try_get(Label.hr_size, layout)
size_ir = Label.try_get(Label.ir_size, layout)
if Label.try_get(Label.shared_blocks, layout):
total_size = max(size_co, size_di, size_hr, size_ir)
else:
# set offset (block) for each function code
# starting with fc = 1, 5, 15
self.runtime.fc_offset[2] = size_co
total_size = size_co + size_di
self.runtime.fc_offset[4] = total_size
total_size += size_ir
for i in (3, 6, 16, 22, 23):
self.runtime.fc_offset[i] = total_size
total_size += size_hr
first_cell = Cell()
self.runtime.registers = [
dataclasses.replace(first_cell) for i in range(total_size)
]
self.runtime.register_count = total_size
self.runtime.type_exception = bool(Label.try_get(Label.type_exception, layout))
defaults = Label.try_get(Label.defaults, layout)
defaults_value = Label.try_get(Label.value, defaults)
defaults_action = Label.try_get(Label.action, defaults)
for key, entry in self.config_types.items():
entry[Label.value] = Label.try_get(key, defaults_value)
if (
action := Label.try_get(key, defaults_action)
) not in self.runtime.action_name_to_id:
raise RuntimeError(f"ERROR illegal action {key} in {defaults_action}")
entry[Label.action] = action
del self.config[Label.setup]
def handle_invalid_address(self):
"""Handle invalid address."""
for entry in Label.try_get(Label.invalid, self.config):
if isinstance(entry, int):
entry = [entry, entry]
for i in range(entry[0], entry[1] + 1):
if i >= self.runtime.register_count:
raise RuntimeError(
f'Error section "{Label.invalid}" addr {entry} out of range'
)
reg = self.runtime.registers[i]
reg.type = CellType.INVALID
del self.config[Label.invalid]
def handle_write_allowed(self):
"""Handle write allowed."""
for entry in Label.try_get(Label.write, self.config):
if isinstance(entry, int):
entry = [entry, entry]
for i in range(entry[0], entry[1] + 1):
if i >= self.runtime.register_count:
raise RuntimeError(
f'Error section "{Label.write}" addr {entry} out of range'
)
reg = self.runtime.registers[i]
if reg.type == CellType.INVALID:
txt = f'ERROR Configuration invalid in section "write" register {i} not defined'
raise RuntimeError(txt)
reg.access = True
del self.config[Label.write]
def handle_types(self):
"""Handle the different types."""
for section, type_entry in self.config_types.items():
layout = Label.try_get(section, self.config)
for entry in layout:
if not isinstance(entry, dict):
entry = {Label.addr: entry}
regs = Label.try_get(Label.addr, entry)
if not isinstance(regs, list):
regs = [regs, regs]
start = regs[0]
if (stop := regs[1]) >= self.runtime.register_count:
raise RuntimeError(f'Error "{section}" {start}, {stop} illegal')
type_entry[Label.method](
start,
stop + 1,
entry.get(Label.value, type_entry[Label.value]),
self.runtime.action_name_to_id[
entry.get(Label.action, type_entry[Label.action])
],
entry.get(Label.parameters, None),
)
del self.config[section]
def handle_repeat(self):
"""Handle repeat."""
for entry in Label.try_get(Label.repeat, self.config):
addr = Label.try_get(Label.addr, entry)
copy_start = addr[0]
copy_end = addr[1]
copy_inx = copy_start - 1
addr_to = Label.try_get(Label.repeat_to, entry)
for inx in range(addr_to[0], addr_to[1] + 1):
copy_inx = copy_start if copy_inx >= copy_end else copy_inx + 1
if inx >= self.runtime.register_count:
raise RuntimeError(
f'Error section "{Label.repeat}" entry {entry} out of range'
)
self.runtime.registers[inx] = dataclasses.replace(
self.runtime.registers[copy_inx]
)
del self.config[Label.repeat]
def setup(self, config, custom_actions) -> None:
"""Load layout from dict with json structure."""
actions = {
Label.increment: self.runtime.action_increment,
Label.random: self.runtime.action_random,
Label.reset: self.runtime.action_reset,
Label.timestamp: self.runtime.action_timestamp,
Label.uptime: self.runtime.action_uptime,
}
if custom_actions:
actions.update(custom_actions)
self.runtime.action_name_to_id = {None: 0}
self.runtime.action_id_to_name = [Label.none]
self.runtime.action_methods = [None]
i = 1
for key, method in actions.items():
self.runtime.action_name_to_id[key] = i
self.runtime.action_id_to_name.append(key)
self.runtime.action_methods.append(method)
i += 1
self.runtime.registerType_name_to_id = {
Label.type_bits: CellType.BITS,
Label.type_uint16: CellType.UINT16,
Label.type_uint32: CellType.UINT32,
Label.type_float32: CellType.FLOAT32,
Label.type_string: CellType.STRING,
Label.next: CellType.NEXT,
Label.invalid: CellType.INVALID,
}
self.runtime.registerType_id_to_name = [None] * len(
self.runtime.registerType_name_to_id
)
for name, cell_type in self.runtime.registerType_name_to_id.items():
self.runtime.registerType_id_to_name[cell_type] = name
self.config = config
self.handle_setup_section()
self.handle_invalid_address()
self.handle_types()
self.handle_write_allowed()
self.handle_repeat()
if self.config:
raise RuntimeError(f"INVALID key in setup: {self.config}")
class ModbusSimulatorContext(ModbusBaseSlaveContext):
"""Modbus simulator.
:param config: A dict with structure as shown below.
:param actions: A dict with "<name>": <function> structure.
:raises RuntimeError: if json contains errors (msg explains what)
It builds and maintains a virtual copy of a device, with simulation of
device specific functions.
The device is described in a dict, user supplied actions will
be added to the builtin actions.
It is used in conjunction with a pymodbus server.
Example::
store = ModbusSimulatorContext(<config dict>, <actions dict>)
StartAsyncTcpServer(<host>, context=store)
Now the server will simulate the defined device with features like:
- invalid addresses
- write protected addresses
- optional control of access for string, uint32, bit/bits
- builtin actions for e.g. reset/datetime, value increment by read
- custom actions
Description of the json file or dict to be supplied::
{
"setup": {
"di size": 0, --> Size of discrete input block (8 bit)
"co size": 0, --> Size of coils block (8 bit)
"ir size": 0, --> Size of input registers block (16 bit)
"hr size": 0, --> Size of holding registers block (16 bit)
"shared blocks": True, --> share memory for all blocks (largest size wins)
"defaults": {
"value": { --> Initial values (can be overwritten)
"bits": 0x01,
"uint16": 122,
"uint32": 67000,
"float32": 127.4,
"string": " ",
},
"action": { --> default action (can be overwritten)
"bits": None,
"uint16": None,
"uint32": None,
"float32": None,
"string": None,
},
},
"type exception": False, --> return IO exception if read/write on non boundary
},
"invalid": [ --> List of invalid addresses, IO exception returned
51, --> single register
[78, 99], --> start, end registers, repeated as needed
],
"write": [ --> allow write, efault is ReadOnly
[5, 5] --> start, end bytes, repeated as needed
],
"bits": [ --> Define bits (1 register == 2 bytes)
[30, 31], --> start, end registers, repeated as needed
{"addr": [32, 34], "value": 0xF1}, --> with value
{"addr": [35, 36], "action": "increment"}, --> with action
{"addr": [37, 38], "action": "increment", "value": 0xF1} --> with action and value
{"addr": [37, 38], "action": "increment", "parameters": {"min": 0, "max": 100}} --> with action with arguments
],
"uint16": [ --> Define uint16 (1 register == 2 bytes)
--> same as type_bits
],
"uint32": [ --> Define 32 bit integers (2 registers == 4 bytes)
--> same as type_bits
],
"float32": [ --> Define 32 bit floats (2 registers == 4 bytes)
--> same as type_bits
],
"string": [ --> Define strings (variable number of registers (each 2 bytes))
[21, 22], --> start, end registers, define 1 string
{"addr": 23, 25], "value": "ups"}, --> with value
{"addr": 26, 27], "action": "user"}, --> with action
{"addr": 28, 29], "action": "", "value": "user"} --> with action and value
],
"repeat": [ --> allows to repeat section e.g. for n devices
{"addr": [100, 200], "to": [50, 275]} --> Repeat registers 100-200 to 50+ until 275
]
}
"""
# --------------------------------------------
# External interfaces
# --------------------------------------------
start_time = int(datetime.now().timestamp())
def __init__(
self, config: dict[str, Any], custom_actions: dict[str, Callable] | None
) -> None:
"""Initialize."""
self.registers: list[Cell] = []
self.fc_offset: dict[int, int] = {}
self.register_count = 0
self.type_exception = False
self.action_name_to_id: dict[str, int] = {}
self.action_id_to_name: list[str] = []
self.action_methods: list[Callable] = []
self.registerType_name_to_id: dict[str, int] = {}
self.registerType_id_to_name: list[str] = []
Setup(self).setup(config, custom_actions)
# --------------------------------------------
# Simulator server interface
# --------------------------------------------
def get_text_register(self, register):
"""Get raw register."""
reg = self.registers[register]
text_cell = TextCell()
text_cell.type = self.registerType_id_to_name[reg.type]
text_cell.access = str(reg.access)
text_cell.count_read = str(reg.count_read)
text_cell.count_write = str(reg.count_write)
text_cell.action = self.action_id_to_name[reg.action]
if reg.action_parameters:
text_cell.action = f"{text_cell.action}({reg.action_parameters})"
if reg.type in (CellType.INVALID, CellType.UINT16, CellType.NEXT):
text_cell.value = str(reg.value)
build_len = 0
elif reg.type == CellType.BITS:
text_cell.value = hex(reg.value)
build_len = 0
elif reg.type == CellType.UINT32:
tmp_regs = [reg.value, self.registers[register + 1].value]
text_cell.value = str(self.build_value_from_registers(tmp_regs, True))
build_len = 1
elif reg.type == CellType.FLOAT32:
tmp_regs = [reg.value, self.registers[register + 1].value]
text_cell.value = str(self.build_value_from_registers(tmp_regs, False))
build_len = 1
else: # reg.type == CellType.STRING:
j = register
text_cell.value = ""
while True:
text_cell.value += str(
self.registers[j].value.to_bytes(2, "big"),
encoding="utf-8",
errors="ignore",
)
j += 1
if self.registers[j].type != CellType.NEXT:
break
build_len = j - register - 1
reg_txt = f"{register}-{register + build_len}" if build_len else f"{register}"
return reg_txt, text_cell
# --------------------------------------------
# Modbus server interface
# --------------------------------------------
_write_func_code = (5, 6, 15, 16, 22, 23)
_bits_func_code = (1, 2, 5, 15)
def loop_validate(self, address, end_address, fx_write):
"""Validate entry in loop.
:meta private:
"""
i = address
while i < end_address:
reg = self.registers[i]
if fx_write and not reg.access or reg.type == CellType.INVALID:
return False
if not self.type_exception:
i += 1
continue
if reg.type == CellType.NEXT:
return False
if reg.type in (CellType.BITS, CellType.UINT16):
i += 1
elif reg.type in (CellType.UINT32, CellType.FLOAT32):
if i + 1 >= end_address:
return False
i += 2
else:
i += 1
while i < end_address:
if self.registers[i].type == CellType.NEXT:
i += 1
return True
def validate(self, func_code, address, count=1):
"""Check to see if the request is in range.
:meta private:
"""
if func_code in self._bits_func_code:
# Bit count, correct to register count
count = int((count + WORD_SIZE - 1) / WORD_SIZE)
address = int(address / 16)
real_address = self.fc_offset[func_code] + address
if real_address < 0 or real_address > self.register_count:
return False
fx_write = func_code in self._write_func_code
return self.loop_validate(real_address, real_address + count, fx_write)
def getValues(self, func_code, address, count=1):
"""Return the requested values of the datastore.
:meta private:
"""
result = []
if func_code not in self._bits_func_code:
real_address = self.fc_offset[func_code] + address
for i in range(real_address, real_address + count):
reg = self.registers[i]
parameters = reg.action_parameters if reg.action_parameters else {}
if reg.action:
self.action_methods[reg.action](self.registers, i, reg, **parameters)
self.registers[i].count_read += 1
result.append(reg.value)
else:
# bit access
real_address = self.fc_offset[func_code] + int(address / 16)
bit_index = address % 16
reg_count = int((count + bit_index + 15) / 16)
for i in range(real_address, real_address + reg_count):
reg = self.registers[i]
if reg.action:
parameters = reg.action_parameters or {}
self.action_methods[reg.action](
self.registers, i, reg, **parameters
)
self.registers[i].count_read += 1
while count and bit_index < 16:
result.append(bool(reg.value & (2**bit_index)))
count -= 1
bit_index += 1
bit_index = 0
return result
def setValues(self, func_code, address, values):
"""Set the requested values of the datastore.
:meta private:
"""
if func_code not in self._bits_func_code:
real_address = self.fc_offset[func_code] + address
for value in values:
self.registers[real_address].value = value
self.registers[real_address].count_write += 1
real_address += 1
return
# bit access
real_address = self.fc_offset[func_code] + int(address / 16)
bit_index = address % 16
for value in values:
bit_mask = 2**bit_index
if bool(value):
self.registers[real_address].value |= bit_mask
else:
self.registers[real_address].value &= ~bit_mask
self.registers[real_address].count_write += 1
bit_index += 1
if bit_index == 16:
bit_index = 0
real_address += 1
return
# --------------------------------------------
# Internal action methods
# --------------------------------------------
@classmethod
def action_random(cls, registers, inx, cell, minval=1, maxval=65536):
"""Update with random value.
:meta private:
"""
if cell.type in (CellType.BITS, CellType.UINT16):
registers[inx].value = random.randint(int(minval), int(maxval))
elif cell.type == CellType.FLOAT32:
regs = cls.build_registers_from_value(
random.uniform(float(minval), float(maxval)), False
)
registers[inx].value = regs[0]
registers[inx + 1].value = regs[1]
elif cell.type == CellType.UINT32:
regs = cls.build_registers_from_value(
random.randint(int(minval), int(maxval)), True
)
registers[inx].value = regs[0]
registers[inx + 1].value = regs[1]
@classmethod
def action_increment(cls, registers, inx, cell, minval=None, maxval=None):
"""Increment value reset with overflow.
:meta private:
"""
reg = registers[inx]
reg2 = registers[inx + 1]
if cell.type in (CellType.BITS, CellType.UINT16):
value = reg.value + 1
if maxval and value > maxval:
value = minval
if minval and value < minval:
value = minval
reg.value = value
elif cell.type == CellType.FLOAT32:
tmp_reg = [reg.value, reg2.value]
value = cls.build_value_from_registers(tmp_reg, False)
value += 1.0
if maxval and value > maxval:
value = minval
if minval and value < minval:
value = minval
new_regs = cls.build_registers_from_value(value, False)
reg.value = new_regs[0]
reg2.value = new_regs[1]
elif cell.type == CellType.UINT32:
tmp_reg = [reg.value, reg2.value]
value = cls.build_value_from_registers(tmp_reg, True)
value += 1
if maxval and value > maxval:
value = minval
if minval and value < minval:
value = minval
new_regs = cls.build_registers_from_value(value, True)
reg.value = new_regs[0]
reg2.value = new_regs[1]
@classmethod
def action_timestamp(cls, registers, inx, _cell, **_parameters):
"""Set current time.
:meta private:
"""
system_time = datetime.now()
registers[inx].value = system_time.year
registers[inx + 1].value = system_time.month - 1
registers[inx + 2].value = system_time.day
registers[inx + 3].value = system_time.weekday() + 1
registers[inx + 4].value = system_time.hour
registers[inx + 5].value = system_time.minute
registers[inx + 6].value = system_time.second
@classmethod
def action_reset(cls, _registers, _inx, _cell, **_parameters):
"""Reboot server.
:meta private:
"""
raise RuntimeError("RESET server")
@classmethod
def action_uptime(cls, registers, inx, cell, **_parameters):
"""Return uptime in seconds.
:meta private:
"""
value = int(datetime.now().timestamp()) - cls.start_time + 1
if cell.type in (CellType.BITS, CellType.UINT16):
registers[inx].value = value
elif cell.type == CellType.FLOAT32:
regs = cls.build_registers_from_value(value, False)
registers[inx].value = regs[0]
registers[inx + 1].value = regs[1]
elif cell.type == CellType.UINT32:
regs = cls.build_registers_from_value(value, True)
registers[inx].value = regs[0]
registers[inx + 1].value = regs[1]
# --------------------------------------------
# Internal helper methods
# --------------------------------------------
def validate_type(self, func_code, real_address, count) -> bool:
"""Check if request is done against correct type.
:meta private:
"""
check: tuple
if func_code in self._bits_func_code:
# Bit access
check = (CellType.BITS, -1)
reg_step = 1
elif count % 2:
# 16 bit access
check = (CellType.UINT16, CellType.STRING)
reg_step = 1
else:
check = (CellType.UINT32, CellType.FLOAT32, CellType.STRING)
reg_step = 2
for i in range(real_address, real_address + count, reg_step):
if self.registers[i].type in check:
continue
if self.registers[i].type is CellType.NEXT:
continue
return False
return True
@classmethod
def build_registers_from_value(cls, value, is_int):
"""Build registers from int32 or float32."""
regs = [0, 0]
if is_int:
value_bytes = int.to_bytes(value, 4, "big")
else:
value_bytes = struct.pack(">f", value)
regs[0] = int.from_bytes(value_bytes[:2], "big")
regs[1] = int.from_bytes(value_bytes[-2:], "big")
return regs
@classmethod
def build_value_from_registers(cls, registers, is_int):
"""Build int32 or float32 value from registers."""
value_bytes = int.to_bytes(registers[0], 2, "big") + int.to_bytes(
registers[1], 2, "big"
)
if is_int:
value = int.from_bytes(value_bytes, "big")
else:
value = struct.unpack(">f", value_bytes)[0]
return value

View File

@@ -0,0 +1,344 @@
"""Modbus Server Datastore.
For each server, you will create a ModbusServerContext and pass
in the default address space for each data access. The class
will create and manage the data.
Further modification of said data accesses should be performed
with [get,set][access]Values(address, count)
Datastore Implementation
-------------------------
There are two ways that the server datastore can be implemented.
The first is a complete range from "address" start to "count"
number of indices. This can be thought of as a straight array::
data = range(1, 1 + count)
[1,2,3,...,count]
The other way that the datastore can be implemented (and how
many devices implement it) is a associate-array::
data = {1:"1", 3:"3", ..., count:"count"}
[1,3,...,count]
The difference between the two is that the latter will allow
arbitrary gaps in its datastore while the former will not.
This is seen quite commonly in some modbus implementations.
What follows is a clear example from the field:
Say a company makes two devices to monitor power usage on a rack.
One works with three-phase and the other with a single phase. The
company will dictate a modbus data mapping such that registers::
n: phase 1 power
n+1: phase 2 power
n+2: phase 3 power
Using this, layout, the first device will implement n, n+1, and n+2,
however, the second device may set the latter two values to 0 or
will simply not implemented the registers thus causing a single read
or a range read to fail.
I have both methods implemented, and leave it up to the user to change
based on their preference.
"""
# pylint: disable=missing-type-doc
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import Any, Generic, TypeVar
from pymodbus.exceptions import ParameterException
# ---------------------------------------------------------------------------#
# Datablock Storage
# ---------------------------------------------------------------------------#
V = TypeVar('V', list, dict[int, Any])
class BaseModbusDataBlock(ABC, Generic[V]):
"""Base class for a modbus datastore.
Derived classes must create the following fields:
@address The starting address point
@defult_value The default value of the datastore
@values The actual datastore values
Derived classes must implemented the following methods:
validate(self, address, count=1)
getValues(self, address, count=1)
setValues(self, address, values)
reset(self)
Derived classes can implemented the following async methods:
async_getValues(self, address, count=1)
async_setValues(self, address, values)
but are not needed since these standard call the sync. methods.
"""
values: V
address: int
default_value: Any
@abstractmethod
def validate(self, address:int, count=1) -> bool:
"""Check to see if the request is in range.
:param address: The starting address
:param count: The number of values to test for
:raises TypeError:
"""
async def async_getValues(self, address: int, count=1) -> Iterable:
"""Return the requested values from the datastore.
:param address: The starting address
:param count: The number of values to retrieve
:raises TypeError:
"""
return self.getValues(address, count)
@abstractmethod
def getValues(self, address:int, count=1) -> Iterable:
"""Return the requested values from the datastore.
:param address: The starting address
:param count: The number of values to retrieve
:raises TypeError:
"""
async def async_setValues(self, address: int, values: list[int|bool]) -> None:
"""Set the requested values in the datastore.
:param address: The starting address
:param values: The values to store
:raises TypeError:
"""
self.setValues(address, values)
@abstractmethod
def setValues(self, address:int, values) -> None:
"""Set the requested values in the datastore.
:param address: The starting address
:param values: The values to store
:raises TypeError:
"""
def __str__(self):
"""Build a representation of the datastore.
:returns: A string representation of the datastore
"""
return f"DataStore({len(self.values)}, {self.default_value})"
def __iter__(self):
"""Iterate over the data block data.
:returns: An iterator of the data block data
"""
if isinstance(self.values, dict):
return iter(self.values.items())
return enumerate(self.values, self.address)
class ModbusSequentialDataBlock(BaseModbusDataBlock[list]):
"""Creates a sequential modbus datastore."""
def __init__(self, address, values):
"""Initialize the datastore.
:param address: The starting address of the datastore
:param values: Either a list or a dictionary of values
"""
self.address = address
if hasattr(values, "__iter__"):
self.values = list(values)
else:
self.values = [values]
self.default_value = self.values[0].__class__()
@classmethod
def create(cls):
"""Create a datastore.
With the full address space initialized to 0x00
:returns: An initialized datastore
"""
return cls(0x00, [0x00] * 65536)
def default(self, count, value=False):
"""Use to initialize a store to one value.
:param count: The number of fields to set
:param value: The default value to set to the fields
"""
self.default_value = value
self.values = [self.default_value] * count
self.address = 0x00
def reset(self):
"""Reset the datastore to the initialized default value."""
self.values = [self.default_value] * len(self.values)
def validate(self, address, count=1):
"""Check to see if the request is in range.
:param address: The starting address
:param count: The number of values to test for
:returns: True if the request in within range, False otherwise
"""
result = self.address <= address
result &= (self.address + len(self.values)) >= (address + count)
return result
def getValues(self, address, count=1):
"""Return the requested values of the datastore.
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
start = address - self.address
return self.values[start : start + count]
def setValues(self, address, values):
"""Set the requested values of the datastore.
:param address: The starting address
:param values: The new values to be set
"""
if not isinstance(values, list):
values = [values]
start = address - self.address
self.values[start : start + len(values)] = values
class ModbusSparseDataBlock(BaseModbusDataBlock[dict[int, Any]]):
"""A sparse modbus datastore.
E.g Usage.
sparse = ModbusSparseDataBlock({10: [3, 5, 6, 8], 30: 1, 40: [0]*20})
This would create a datablock with 3 blocks
One starts at offset 10 with length 4, one at 30 with length 1, and one at 40 with length 20
sparse = ModbusSparseDataBlock([10]*100)
Creates a sparse datablock of length 100 starting at offset 0 and default value of 10
sparse = ModbusSparseDataBlock() --> Create empty datablock
sparse.setValues(0, [10]*10) --> Add block 1 at offset 0 with length 10 (default value 10)
sparse.setValues(30, [20]*5) --> Add block 2 at offset 30 with length 5 (default value 20)
Unless 'mutable' is set to True during initialization, the datablock cannot be altered with
setValues (new datablocks cannot be added)
"""
def __init__(self, values=None, mutable=True):
"""Initialize a sparse datastore.
Will only answer to addresses registered,
either initially here, or later via setValues()
:param values: Either a list or a dictionary of values
:param mutable: Whether the data-block can be altered later with setValues (i.e add more blocks)
If values is a list, a sequential datablock will be created.
If values is a dictionary, it should be in {offset: <int | list>} format
For each list, a sparse datablock is created, starting at 'offset' with the length of the list
For each integer, the value is set for the corresponding offset.
"""
self.values = {}
self._process_values(values)
self.mutable = mutable
self.default_value = self.values.copy()
@classmethod
def create(cls, values=None):
"""Create sparse datastore.
Use setValues to initialize registers.
:param values: Either a list or a dictionary of values
:returns: An initialized datastore
"""
return cls(values)
def reset(self):
"""Reset the store to the initially provided defaults."""
self.values = self.default_value.copy()
def validate(self, address, count=1):
"""Check to see if the request is in range.
:param address: The starting address
:param count: The number of values to test for
:returns: True if the request in within range, False otherwise
"""
if not count:
return False
handle = set(range(address, address + count))
return handle.issubset(set(iter(self.values.keys())))
def getValues(self, address, count=1):
"""Return the requested values of the datastore.
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
return [self.values[i] for i in range(address, address + count)]
def _process_values(self, values):
"""Process values."""
def _process_as_dict(values):
for idx, val in iter(values.items()):
if isinstance(val, (list, tuple)):
for i, v_item in enumerate(val):
self.values[idx + i] = v_item
else:
self.values[idx] = int(val)
if isinstance(values, dict):
_process_as_dict(values)
return
if hasattr(values, "__iter__"):
values = dict(enumerate(values))
elif values is None:
values = {} # Must make a new dict here per instance
else:
raise ParameterException(
"Values for datastore must be a list or dictionary"
)
_process_as_dict(values)
def setValues(self, address, values, use_as_default=False):
"""Set the requested values of the datastore.
:param address: The starting address
:param values: The new values to be set
:param use_as_default: Use the values as default
:raises ParameterException:
"""
if isinstance(values, dict):
new_offsets = list(set(values.keys()) - set(self.values.keys()))
if new_offsets and not self.mutable:
raise ParameterException(f"Offsets {new_offsets} not in range")
self._process_values(values)
else:
if not isinstance(values, list):
values = [values]
for idx, val in enumerate(values):
if address + idx not in self.values and not self.mutable:
raise ParameterException("Offset {address+idx} not in range")
self.values[address + idx] = val
if use_as_default:
for idx, val in iter(self.values.items()):
self.default_value[idx] = val