venv added, updated
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
250
myenv/lib/python3.12/site-packages/pymodbus/datastore/context.py
Normal file
250
myenv/lib/python3.12/site-packages/pymodbus/datastore/context.py
Normal 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())
|
||||
129
myenv/lib/python3.12/site-packages/pymodbus/datastore/remote.py
Normal file
129
myenv/lib/python3.12/site-packages/pymodbus/datastore/remote.py
Normal 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
|
||||
@@ -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
|
||||
344
myenv/lib/python3.12/site-packages/pymodbus/datastore/store.py
Normal file
344
myenv/lib/python3.12/site-packages/pymodbus/datastore/store.py
Normal 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
|
||||
Reference in New Issue
Block a user