Initial commit for tac2100_compteur_mbus2mqtt
This commit is contained in:
+21
@@ -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,
|
||||
)
|
||||
Vendored
Executable
BIN
Binary file not shown.
Vendored
Executable
BIN
Binary file not shown.
Vendored
Executable
BIN
Binary file not shown.
Vendored
Executable
BIN
Binary file not shown.
Vendored
Executable
BIN
Binary file not shown.
+195
@@ -0,0 +1,195 @@
|
||||
"""Context for datastore."""
|
||||
# pylint: disable=missing-type-doc
|
||||
from pymodbus.datastore.store import ModbusSequentialDataBlock
|
||||
from pymodbus.exceptions import NoSuchSlaveException
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
class ModbusBaseSlaveContext: # pylint: disable=too-few-public-methods
|
||||
"""Interface for a modbus slave data context.
|
||||
|
||||
Derived classes must implemented the following methods:
|
||||
reset(self)
|
||||
validate(self, fx, address, count=1)
|
||||
getValues(self, fx, address, count=1)
|
||||
setValues(self, fx, 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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Slave Contexts
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusSlaveContext(ModbusBaseSlaveContext):
|
||||
"""This creates a modbus data model with each data access stored in a block."""
|
||||
|
||||
def __init__(self, *_args, **kwargs):
|
||||
"""Initialize the datastores.
|
||||
|
||||
:param kwargs: Each element is a ModbusDataBlock
|
||||
|
||||
"di" - Discrete Inputs initializer
|
||||
"co" - Coils initializer
|
||||
"hr" - Holding Register initializer
|
||||
"ir" - Input Registers iniatializer
|
||||
"""
|
||||
self.store = {}
|
||||
self.store["d"] = kwargs.get("di", ModbusSequentialDataBlock.create())
|
||||
self.store["c"] = kwargs.get("co", ModbusSequentialDataBlock.create())
|
||||
self.store["i"] = kwargs.get("ir", ModbusSequentialDataBlock.create())
|
||||
self.store["h"] = kwargs.get("hr", ModbusSequentialDataBlock.create())
|
||||
self.zero_mode = kwargs.get("zero_mode", False)
|
||||
|
||||
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())
|
||||
+129
@@ -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 in self._write_fc:
|
||||
func_fc = self.__set_callbacks[f"{group_fx}{fc_as_hex}"]
|
||||
if fc_as_hex in {0x0F, 0x10}:
|
||||
self.result = func_fc(address, values)
|
||||
else:
|
||||
self.result = func_fc(address, values[0])
|
||||
if self.result.isError():
|
||||
return self.result
|
||||
return None
|
||||
|
||||
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."""
|
||||
kwargs = {}
|
||||
if self.slave:
|
||||
kwargs["slave"] = self.slave
|
||||
self.__get_callbacks = {
|
||||
"d": lambda a, c: self._client.read_discrete_inputs( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"c": lambda a, c: self._client.read_coils( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"h": lambda a, c: self._client.read_holding_registers( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"i": lambda a, c: self._client.read_input_registers( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
}
|
||||
self.__set_callbacks = {
|
||||
"d5": lambda a, v: self._client.write_coil( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"d15": lambda a, v: self._client.write_coils( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"c5": lambda a, v: self._client.write_coil( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"c15": lambda a, v: self._client.write_coils( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"h6": lambda a, v: self._client.write_register( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"h16": lambda a, v: self._client.write_registers( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"i6": lambda a, v: self._client.write_register( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"i16": lambda a, v: self._client.write_registers( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
}
|
||||
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
|
||||
+785
@@ -0,0 +1,785 @@
|
||||
"""Pymodbus ModbusSimulatorContext."""
|
||||
import dataclasses
|
||||
import random
|
||||
import struct
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
|
||||
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_kwargs: Dict[str, Any] = None
|
||||
count_read: int = 0
|
||||
count_write: int = 0
|
||||
|
||||
|
||||
@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"
|
||||
kwargs: str = "kwargs"
|
||||
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 = None
|
||||
self.config_types = {
|
||||
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_kwargs):
|
||||
"""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_kwargs = action_kwargs
|
||||
|
||||
def handle_type_uint16(self, start, stop, value, action, action_kwargs):
|
||||
"""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_kwargs = action_kwargs
|
||||
|
||||
def handle_type_uint32(self, start, stop, value, action, action_kwargs):
|
||||
"""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_kwargs = action_kwargs
|
||||
regs[1].value = regs_value[1]
|
||||
regs[1].type = CellType.NEXT
|
||||
|
||||
def handle_type_float32(self, start, stop, value, action, action_kwargs):
|
||||
"""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_kwargs = action_kwargs
|
||||
regs[1].value = regs_value[1]
|
||||
regs[1].type = CellType.NEXT
|
||||
|
||||
def handle_type_string(self, start, stop, value, action, action_kwargs):
|
||||
"""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_kwargs = action_kwargs
|
||||
|
||||
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.kwargs, 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:
|
||||
"""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 == 1 byte)
|
||||
[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", "kwargs": {"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:
|
||||
"""Initialize."""
|
||||
self.registers: List[int] = []
|
||||
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 = Cell()
|
||||
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_kwargs:
|
||||
text_cell.action = f"{text_cell.action}({reg.action_kwargs})"
|
||||
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]
|
||||
kwargs = reg.action_kwargs if reg.action_kwargs else {}
|
||||
if reg.action:
|
||||
self.action_methods[reg.action](self.registers, i, reg, **kwargs)
|
||||
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:
|
||||
self.action_methods[reg.action](
|
||||
self.registers, i, reg, reg.action_kwargs
|
||||
)
|
||||
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, **_kwargs):
|
||||
"""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, **_kwargs):
|
||||
"""Reboot server.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
raise RuntimeError("RESET server")
|
||||
|
||||
@classmethod
|
||||
def action_uptime(cls, registers, inx, cell, **_kwargs):
|
||||
"""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):
|
||||
"""Check if request is done against correct type
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
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
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
"""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 pymodbus.exceptions import NotImplementedException, ParameterException
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Datablock Storage
|
||||
# ---------------------------------------------------------------------------#
|
||||
class BaseModbusDataBlock:
|
||||
"""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)
|
||||
"""
|
||||
|
||||
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 # pylint: disable=attribute-defined-outside-init
|
||||
self.values = [ # pylint: disable=attribute-defined-outside-init
|
||||
self.default_value
|
||||
] * count
|
||||
self.address = 0x00 # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
def reset(self):
|
||||
"""Reset the datastore to the initialized default value."""
|
||||
self.values = [ # pylint: disable=attribute-defined-outside-init
|
||||
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
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Address Check")
|
||||
|
||||
def getValues(self, address, count=1):
|
||||
"""Return the requested values from the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to retrieve
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Value Retrieve")
|
||||
|
||||
def setValues(self, address, values):
|
||||
"""Return the requested values from the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param values: The values to store
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Value Retrieve")
|
||||
|
||||
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):
|
||||
"""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 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):
|
||||
"""Create 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 starting at
|
||||
offset 10 with length 4 , 30 with length 1 and 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)
|
||||
|
||||
if mutable is set to True during initialization, the datablock can not be altered with
|
||||
setValues (new datablocks can not 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: The data-block can be altered later with setValues(i.e add more blocks)
|
||||
|
||||
If values are list , This is as good as sequential datablock.
|
||||
Values as dictionary should be in {offset: <values>} format, if values
|
||||
is a list, a sparse datablock is created starting at offset with the length of values.
|
||||
If values is a integer, then the value is set for the corresponding offset.
|
||||
|
||||
"""
|
||||
self.values = {}
|
||||
self._process_values(values)
|
||||
self.mutable = mutable
|
||||
self.default_value = self.values.copy()
|
||||
self.address = next(iter(self.values.keys()), None)
|
||||
|
||||
@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 not self.address:
|
||||
self.address = next(iter(self.values.keys()), None)
|
||||
if use_as_default:
|
||||
for idx, val in iter(self.values.items()):
|
||||
self.default_value[idx] = val
|
||||
Reference in New Issue
Block a user