/MODBUS

Universal MODBUS interface for Web API usage

Primary LanguagePythonBSD 3-Clause "New" or "Revised" LicenseBSD-3-Clause

A Universal TCP MODBUS Client

READER

A universal TCP MODBUS interface, where the mapping of the parameters to coil, discrete input, input and holding registers is entirely defined by a JSON file, with no modification to the coding required whatsoever. The device class config file comprises a dictionary, where the key represents a MODBUS register, multiples or a single byte - major or minor - of it. Note: a register key cannot be read out twice. Its value comprises a dictionary of various features, namely

Feature Description Applied To Mandatory/
Optional
Output
parameter unique parameter name to identify register space all mandatory yes
function data type, see table below all mandatory AVRO data type
description parameter description all optional yes
alias alternative identifier all optional yes
unit units int/float optional yes
defaultValue default value all but map of bits optional yes
map see below all optional no
isTag tag a parameter, for influxDB (boolean) all but map of bits optional yes
min minimum of parameter value, write error if exceeded input & holding register, int/float optional yes
max maximum of parameter value, write error if exceeded input & holding register, int/float optional yes
multiplier multiply by register value:
value = multiplier x register [+ offset]
input & holding register, int optional no
offset add offset to register value:
value = [multiplier x] register + offset
input & holding register, int optional no

Features, such as "value" and "datatype" (AVRO naming conventions) are reserved for the output only. Same applies to "parameter_alt" and "value_alt". They are provided in case maps are used. Additional features may be provided in the client registry mapping, which will be merely passed on to the output.

A map may be specified if a value needs to match an entry of a given list. In this case the corresponding field value of the map is passed on to the output as feature "value_alt". A map might also contain entries matching the 8 bits of the leading or trailing byte of a register. In that case the values of the individual bits are provided under the feature "parameter_alt".

Register keys are in the following formates: e.g. "30011" addressing the 12th register of the input register class, "30011/1" or "30011/2" for the leading and trailing byte of the 16 bit register, respectively. Furthermore, "30011/30012" or "30011/30014" address 32 or 64 bit broad registers starting at the 12th register, if registers are in zeroMode = True at the server's configuration. A start/end register is to be provided for strings only, whereas the specification of an end register is not required for other data types.

Note: the number of registers per class de facto extends to 0xFFFF, such that 65536 registers could be utilized.

A function needs to be defined for input and holding registers that translates the 8, 16, 32, or 64 bits into appropriate values. This function is in the form, e.g. "decode_32bit_uint" (see below for a selection):

Function Value Avro Data Type
8 bits of 1st/2nd byte decode_bits boolean
string of variable length decode_string string
8 int of 1st/2nd byte decode_8bit_int int
8 uint of 1st/2nd byte decode_8bit_uint int
16 int decode_16bit_int int
16 uint decode_16bit_uint int
32 int decode_32bit_int int
32 uint decode_32bit_uint int
16 float decode_16bit_float float
32 float decode_32bit_float float
64 int decode_64bit_int long
64 uint decode_64bit_uint long
64 float decode_64bit_float double

Gaps between registers are permitted. A check on the uniqueness of "parameter" is performed as well as validity checks on the JSON keys.

The JSON file for the client configuration and mapping of registers to parameters is defined as follows.

{
  "endianness": {
    "byteorder": "<",
    "wordorder": ">"
  },
  "mapping": {
    "10000": {
      "parameter": "UnitOn",
      "description": "Unit On status: TRUE = Unit ON"
    },
    "10001": {
      "parameter": "Unit_Alarm"
    },
    "30001": {
      "function": "decode_16bit_uint",
      "parameter": "Operating State",
      "map": {
        "0": "Idling ‐ ready to start",
        "2": "Starting",
        "3": "Running",
        "5": "Stopping",
        "6": "Error Lockout",
        "7": "Error",
        "8": "Helium Cool Down",
        "9": "Power related Error",
        "15": "Recovered from Error"
      },
      "isTag": true
    },
    "30002": {
      "function": "decode_16bit_uint",
      "parameter": "Compressor Running",
      "map": {
        "0": "Off",
        "1": "On"
      }
    },
    "30003": {
      "function": "decode_32bit_float",
      "parameter": "Warning State",
      "map": {
        "0": "No warnings",
        "-1": "Coolant IN running High",
        "-2": "Coolant IN running Low",
        "-4": "Coolant OUT running High",
        "-8": "Coolant OUT running Low",
        "-16": "Oil running High",
        "-32": "Oil running Low",
        "-64": "Helium running High",
        "-128": "Helium running Low",
        "-256": "Low Pressure running High",
        "-512": "Low Pressure running Low",
        "-1024": "High Pressure running High",
        "-2048": "High Pressure running Low",
        "-4096": "Delta Pressure running High",
        "-8192": "Delta Pressure running Low",
        "-131072": "Static Pressure running High",
        "-262144": "Static Pressure running Low",
        "-524288": "Cold head motor Stall"
      }
    },
    "30011": {
      "function": "decode_32bit_float",
      "parameter": "Oil Temp",
      "description": "unit is provided here...",
      "unit": "e.g. Fahrenheit",
      "isTag": true
    },
    "30031": {
      "function": "decode_16bit_uint",
      "parameter": "Panel Serial Number",
      "description": "This is supposed to be the Panel Serial Number"
    },
    "30033": {
      "function": "decode_16bit_uint",
      "parameter": "Software Rev"
    },
    "30034/1": {
      "function": "decode_bits",
      "parameter": "TEST1",
      "default": "test7",
      "map": {
        "0b10000000": "test7"
      }
    },
    "30034/2": {
      "function": "decode_bits",
      "parameter": "TEST2",
      "map": {
        "0b00000001": "test0",
        "0b00000010": "test1",
        "0b00000100": "test2",
        "0b00001000": "test3",
        "0b00010000": "test4",
        "0b00100000": "test5",
        "0b01000000": "test6",
        "0b10000000": "test7"
      }
    },
    "40009": {
      "parameter": "Water_Setpoint.SP_r",
      "function": "decode_16bit_int",
      "multiplier": 0.1,
      "offset": -273,
      "description": "Setpoint Water",
      "unit": "DegreesCelsius",
      "max": 50,
      "min": 10,
      "defaultvalue": 24.0
    }
  }
}

Note: "endianness" is optional, default is

{
    "byteorder": "<",
    "wordorder": ">"
}

Before decoding the modbus payloads, please consider that there is some confusion about Little-Endian vs. Big-Endian Word Order. The current modbus client allows the endiannesses of the byteorder (the Byte order of each word) and the wordorder (the endianess of the word, when wordcount is >= 2) to be adjusted (see Parameters):

">" = Endian.Big 
"<" = Endian.Little

Packing/unpacking depends on your CPU's word/byte order. MODBUS messages are always using big endian. BinaryPayloadBuilder will per default use what your CPU uses. The wordorder is applicable only for 32 and 64 bit values. Let's say we need to write a value 0x12345678 to a 32 bit register. The following combinations could be used to write the register see also here.

Word Order Byte order Word1 Word2
Big Big 0x1234 0x5678
Big Little 0x3412 0x7856
Little Big 0x5678 0x1234
Little Little 0x7856 0x3412

One note aside on bit maps to be read out of a register's 1st and/or 2nd byte. Suppose a server's 16-bit register contains following hex value. The bit map of a virtual parameter TEST0 would look like:

         byte/1  byte/2
0x89AB = 1000100110101011
         h      lh      l

where l and h are the low and high bit of the byte, respectively. See also below, when writing to registers with bit maps. The byte order endianness on the client site will absolutely not change the order of bits, whatsoever, if the decode_bits function is applied. What a relief!

Not implemented to date:

  • decoder.bit_chunks() - coils

The result provided for the housekeeping (Kafka producer) is a list of dictionary objects.

Present TCP MODBUS clients versions deploy the synchronous and asynchronous ModbusTcpClients in its version v3.5.2 (as of 2023/10/01).

Run reader:

python3 mb_client_readwrite.py --host <host address> \
                               [--port <host port> (default: 502)] \
                               [--debug] \
                               [--async_mode] \
                               [--config_filename <alternative path to config file>]

WRITER

Only the register classes coil (class 0) and holding registers (class 4) are eligible for writing. Registers in those classes may be changed by utilizing the writer method of MODBUSClient class.

Run writer:

python3 mb_client_readwrite.py --host <host address> \
                               --payload "{\"test 32 bit int\": 720.04, ...}" \
                               [--port <host port> (default: 502)] \
                               [--debug] \
                               [--async_mode] (has no effect, yet!) \
                               [--config_filename <alternative path to config file>]

It accepts - as input - a JSON with one or multiple {"parameter": "value"} pairs, where parameter needs to match (required!) its counterpart in the Reader JSON as already defined above.

Note: parameters defined for MODBUS register classes 1 and 3 will be ignored.

Caveat:

  • Owing to Python's pymodbus module, registers can solely be updated on the whole, which particularly applies for strings, bits and 8 bit-integers of the leading and trailing bytes.
  • No locking mechanism is applied for parallel reading and writing, except for the MODBUS Web API.
  • When updating bit maps for a 16-bit register, say TEST0, note that the payload needs to be formatted as of below. If less than 16 bits are provided, it will populate the register starting at the low bit of byte/1, subsequent will be set to false.
                        byte/1          byte/2
--payload "{\"<parameter>\": [1,0,0,1,0,0,0,1,1,1,0,1,0,1,0,1]}"
                        l             h l             h

MODBUS Web API

Run the Rest API comprising the previously described MODBUS READER and WRITER methods of the MODBUSClient class. An internal locking mechanism prevents reading and writing to the same device simulaneously.

The JSON config file comprises {"parameter": "value"} pairs that can be read and updated on the modbus device, where <device> denotes the extention for each modbus device class (to be updated):

Extension MODBUS Device Class
default simulator
test testing reader & writer integrity
lhx Rack
Cryomech Cryocooler

The appropriate config file for the device class is sought in modbusClient/configFiles directory.

To enroll a new modbus device class, just provide the config file mb_client_config_<device>.json to the DeviceClassConfigs directory. It will be copied appropriately with the Docker container setup.

Run the RestAPI for testing:

python3 mb_client_RestAPI_<sync/async>.py --host <RestAPI host> (default: 127.0.0.1) \
                                          --port <RestAPI port> (default: 5100)

Get the read and write endpoints, by typing in the browser URL:

<RestAPI host>:<RestAPI port>/docs#

Alternatively, invoke cli curl for the Reader:

curl <RestAPI host><RestAPI port>:/modbus/read/<host> 

and for the Writer:

curl <RestAPI host>:<RestAPI port>/modbus/write/<host> -X PUT \
        -H 'accept: application/json' \
        -H 'Content-Type: application/json' \
        -d '{
        "test 32 bit int": 720.04, 
        "write int register": 10, 
        "string of register/1": "YZ", 
        "Coil 0": true, 
        "Coil 1": true, 
        "Coil 10": true
        }'

Caveat:

Whilst environmental parameters are set in the Docker container, they have to be defined separately in the OS environment, ita est:

ServerPort=<MODBUS Server Port>,

ServerIPS=<MODBUS Server IP[, ...]>, and

Debug=True/False

Content

The current repository comprises:

  • class MODBUSClientSync (synchronous version)
    • read_register
    • write_register
    • close
  • class MODBUSClientAsync (Asynchronous version)
    • read_register
    • write_register
  • MODBUS Helper Routine
  • MODBUS RestAPI
    • Synchronous
    • Asynchronous
  • Docker
    • Synchronous RestAPI
    • Asynchronous RestAPI
    • Synchronous RestAPI + ServerSimulator (runs with additional Server Simulator)

MODBUS Server connection and register mapping details are defined in mb_client_config_<device>.json.

For the Conda environment used, see here

Contact: Ralf Antonius Timmermann, AIfA, University Bonn, email: rtimmermann@astro.uni-bonn.de