Skip to content

Instantly share code, notes, and snippets.

@casebeer
Last active October 28, 2023 01:38
Show Gist options
  • Save casebeer/91d9063aa8672d9c19567ff20db09e9f to your computer and use it in GitHub Desktop.
Save casebeer/91d9063aa8672d9c19567ff20db09e9f to your computer and use it in GitHub Desktop.
'''
TUF-2000m
Module to test TUF-2000m ultrasonic flow meter
via a Modbus RTU to Modbus TCP bridge (eByte NA111-A)
'''
import csv
import sys
import os
from pymodbus.client import ModbusTcpClient
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian
# CONSTANTS
# IP address of the RTU-to-TCP bridge
MODBUS_BRIDGE_ADDRESS = None # FIXME
# Modbus RTU bus address of the TUF-2000M
TUF2000M_DEVICE_ADDRESS = None # FIXME
# Schema file based on copy/pasting the TUF-2000m documentation PDF's Modbus registers table
# then cleaning up and making minior tweaks (e.g. adding units to register descriptions)
# See https://images-na.ssl-images-amazon.com/images/I/91CvZHsNYBL.pdf
TUF2000M_REGISTERS_SCHEMA_FILE = 'tuf2000m_registers.csv'
# The TUF-2000m documentation seems to provide register addresses off by one address too high.
# This offest will be added to documented register addresss (loaded from the CSV).
REGISTER_ADDRESS_OFFSET = -1
# override values from env variables if they're set
MODBUS_BRIDGE_ADDRESS = os.environ.get('MODBUS_BRIDGE_ADDRESS', MODBUS_BRIDGE_ADDRESS)
TUF2000M_DEVICE_ADDRESS = int(os.environ.get('TUF2000M_DEVICE_ADDRESS', TUF2000M_DEVICE_ADDRESS))
def make_decoder(registers):
'''
Return a decoder instance loaded with provided register values and
correct endianness settings for the TUF-2000M (i.e. big-endian byte order
and little-endian word order)
'''
return BinaryPayloadDecoder.fromRegisters(
registers,
byteorder=Endian.Big,
wordorder=Endian.Little
)
def bcd_digits(char):
'''interpret a char as two single-digit BCDs in big-endian nibbleorder'''
return [(char >> 4 & 0xf), (char & 0xf)]
def bcd(char):
'''interpret a char as a two-digit BCD in big-endian nibbleorder'''
tens, ones = bcd_digits(char)
return 10 * tens + ones
def convert_bcd_registers(registers):
'''
Interpret a list of two-byte ints as a BCD string, one nibble per decimal digit,
in big-endian word order, little-endian byte order, and big-endian nibble order.
i.e. [ 0x3412, 0x7856 ] => '12345678'
'''
def helper():
for reg in registers:
# low byte first per documentation
for char in [reg & 0xff, reg >> 8 & 0xff]:
# bcd_digits() will give us high nibble first for big-endian nibbleorder
# preferring two single-digit decimal ints since we're interpreting as string of BCDs
for digit in bcd_digits(char):
yield digit
#yield bcd(char) # convert two nibbles into a two-digit decimal integer
# Convert to string
# Note that the docs suggest writing _hex_ digits as "BCD" digits, so we'll
# convert the digits as BC-hex rather than BC-decimal to support this
return ''.join(format(digit, 'x') for digit in helper())
def array_int8_from_int16(num):
'''Convert a single int16 to two int8s'''
return [ num >> 8 & 0xff, num & 0xff ]
# Test struct unpacking of Quality byte for use in Home Assistant as custom modbus sensor
#import struct
#def convert(registers):
# return struct.unpack('>xb', struct.pack('>H', registers[0]))
converters = {
'BITMASK': lambda registers: [format(reg, '0b') for reg in registers],
'INT16': lambda registers: make_decoder(registers).decode_16bit_int(),
'ARR_INT8': lambda registers: array_int8_from_int16(make_decoder(registers).decode_16bit_int()),
'FLOAT32': lambda registers: make_decoder(registers).decode_32bit_float(),
'INT32': lambda registers: make_decoder(registers).decode_32bit_int(),
'UINT32': lambda registers: make_decoder(registers).decode_32bit_uint(),
#'BCD': lambda registers: [bcd(char) for reg in registers for char in [reg & 0xff, reg >> 8 & 0xff]],
'BCD': convert_bcd_registers,
}
def load_schema():
'''Load register definitions from CSV'''
registers = {}
with open(TUF2000M_REGISTERS_SCHEMA_FILE, 'r', encoding='utf-8') as csvfile:
for row in csv.DictReader(csvfile, delimiter='|'):
# n.b. fixing off by one documentation here
register = int(row['registers'].split('-')[0]) + REGISTER_ADDRESS_OFFSET
count = int(row['count'])
#print(f"{row['description']}\t{register}\t{count}")
registers[register] = {
'register': register,
'count': count,
'name': row['description'],
'format': row['format'],
'converter': converters[row['format']],
'note': row['note'],
}
return registers
def test_off_by_one_documentation(client):
'''Retrieve values from documented and (documented - 1) register addresses to compare'''
result = client.read_holding_registers(33, 2, TUF2000M_DEVICE_ADDRESS)
if result.isError():
print(result)
else:
r33 = converters['FLOAT32'](result.registers)
print(f"Temp1 from [32] {get(client, 32)}")
print(f"Temp1 from [33] {r33} (as documented)")
def get(client, register):
'''Get a register or register group from the TUF2000M'''
schema = registers_schema[register]
result = client.read_holding_registers(register, schema['count'], TUF2000M_DEVICE_ADDRESS)
if result.isError():
raise Exception(result)
return schema['converter'](result.registers)
def main():
'''main method'''
if len(sys.argv) > 1:
# pass register addresses to read as command line arguments
registers = [ int(arg) for arg in sys.argv[1:] ]
else:
# if no registers provided as args, print all documented registers
registers = registers_schema.keys()
client = ModbusTcpClient(MODBUS_BRIDGE_ADDRESS)
client.connect()
#test_off_by_one_documentation(client)
#return
for register in registers:
schema = registers_schema[register]
print(f"[{register}] {schema['format']} {schema['name']} = {get(client, register)}")
registers_schema = load_schema()
#pprint(registers_schema)
if __name__ == '__main__':
main()
We can make this file beautiful and searchable if this error is corrected: It looks like row 6 should actually have 1 column, instead of 2. in line 5.
registers|count|description|format|note
0001-0002|2|Flow Rate (m^3/h)|FLOAT32|Unit: m3/h
0003-0004|2|Energy Flow Rate (GJ/h)|FLOAT32|Unit: GJ/h
0005-0006|2|Velocity (m/s)|FLOAT32|Unit: m/s
0007-0008|2|Fluid sound speed (m/s)|FLOAT32|Unit: m/s
0009-0010|2|Positive accumulator|INT32|Unit is selected by M31, and depends on totalizer multiplier
0011-0012|2|Positive decimal fraction|FLOAT32|Same unit as the integer part
0013-0014|2|Negative accumulator|INT32|Long is a signed 4-byte integer, lower byte first
0015-0016|2|Negative decimal fraction|FLOAT32|FLOAT32 is a format of Singular IEEE-754 number, also called FLOAT
0017-0018|2|Positive energy accumulator|INT32|
0019-0020|2|Positive energy decimal fraction|FLOAT32|
0021-0022|2|Negative energy accumulator|INT32|
0023-0024|2|Negative energy decimal fraction|FLOAT32|
0025-0026|2|Net accumulator|INT32|
0027-0028|2|Net decimal fraction|FLOAT32|
0029-0030|2|Net energy accumulator|INT32|
0031-0032|2|Net energy decimal fraction|FLOAT32|
0033-0034|2|Temperature #1/inlet (°C)|FLOAT32|Unit: C
0035-0036|2|Temperature #2/outlet (°C)|FLOAT32|Unit: C
0037-0038|2|Analog input AI3|FLOAT32|
0039-0040|2|Analog input AI4|FLOAT32|
0041-0042|2|Analog input AI5|FLOAT32|
0043-0044|2|Current input at AI3 (mA)|FLOAT32|In unit mA
0045-0046|2|Current input at AI3 (mA)|FLOAT32|In unit mA
0047-0048|2|Current input at AI3 (mA)|FLOAT32|In unit mA
0049-0050|2|System password|BCD|Writable。00H for unlock
0051|1|Password for hardware|BCD|Writable。“A55Ah” for unlock
0053-0055|3|Calendar (date and time)|BCD|Writable。6 Bytes of BCD stands SMHDMY,lower byte first
0056|1|Day+Hour for Auto-Save|BCD|Writable。For example 0512H stands Auto-save on 12:00 on 5th。0012H for 12:00 on everyday
0059|1|Key to input|INT16|Writable
0060|1|Go to Window #|INT16|Writable。
0061|1|LCD Back-lit lights for number of seconds|INT16|Writable。In unit second
0062|1|Times for the beeper|INT16|Writable。Max 255
0062|1|Pulses left for OCT|INT16|Writable。Max 65535
0072|1|Error Code|BITMASK|16bits, see note 4
0077-0078|2|PT100 resistance of inlet (Ohm)|FLOAT32|In unit Ohm
0079-0080|2|PT100 resistance of outlet (Ohm)|FLOAT32|In unit Ohm
0081-0082|2|Total travel time (µs)|FLOAT32|In unit Micro-second
0083-0084|2|Delta travel time (ns)|FLOAT32|In unit Nino-second
0085-0086|2|Upstream travel time (µs)|FLOAT32|In unit Micro-second
0087-0088|2|Downstream travel time (µs)|FLOAT32|In unit Micro-second
0089-0090|2|Output current (mA)|FLOAT32|In unit mA
0092|1|Working step and Signal Quality|ARR_INT8|The high byte is the step and low for signal quality, range 00-99,the larger the better.
0093|1|Upstream strength|INT16|Range 0-2047
0094|1|Downstream strength|INT16|Range 0-2047
0096|1|Language used in user interface|INT16|0:English, 1:Chinese Other language will be supported later
0097-0098|2|The rate of the measured travel time by the calculated travel time. (%)|FLOAT32|Normal 100+-3%
0099-0100|2|Reynolds number|FLOAT32|
0101-0102|2|Pipe Reynolds factor|FLOAT32|
0103-0104|2|Working Timer (s)|UINT32|unsigned,in second
0105-0106|2|Total working time (s)|UINT32|unsigned,in second
0105-0106|2|Total power on-off time|UINT32|Unsigned
0113-0114|2|Net accumulator (m^3)|FLOAT32|In Cubic Meter,float
0115-0116|2|Positive accumulator (m^3)|FLOAT32|In Cubic Meter,float
0117-0118|2|Negative accumulator (m^3)|FLOAT32|In Cubic Meter,float
0119-0120|2|Net energy accumulator (GJ)|FLOAT32|In GJ,float
0121-0122|2|Positive energy accumulator (GJ)|FLOAT32|In GJ,float
0123-0124|2|Negative energy accumulator (GJ)|FLOAT32|In GJ,float
0125-0126|2|Flow for today (m^3)|FLOAT32|In Cubic Meter,float
0127-0128|2|Flow for this month (m^3)|FLOAT32|In Cubic Meter,float
0129-0130|2|Manual accumulator|INT32|
0131-0132|2|Manual accumulator decimal fraction|FLOAT32|
0133-0134|2|Batch accumulator|INT32|
0135-0136|2|Batch accumulator decimal fraction|FLOAT32|
0137-0138|2|Flow for today|INT32|
0139-0140|2|Flow for today decimal fraction|FLOAT32|
0141-0142|2|Flow for this month|INT32|
0143-0144|2|Flow for this month decimal fraction|FLOAT32|
0145-0146|2|Flow for this year|INT32|
0147-0148|2|Flow for this year decimal fraction|FLOAT32|
0158|1|Current display window|INT16|
0165-0166|2|Failure timer (s)|INT32|In unit second
0173-0174|2|Current output frequency (Hz)|FLOAT32|Unit:Hz
0175-0176|2|Current output with 4-20mA (mA)|FLOAT32|Unit:mA
0181-0182|2|Temperature difference (°C)|FLOAT32|Unit:C
0183-0184|2|Lost flow for period of last power off (m^3)|FLOAT32|Unit:Cubic Meter
0185-0186|2|Clock coefficient (should be < 0.1)|FLOAT32|Should less than 0.1
0187-0188|2|Total time for Auto-Save|FLOAT32|Time to save by 0056
0189-0190|2|POS flow for Auto-Save|FLOAT32|Time to save by 0056
0191-0192|2|Flow rate for Auto-Save|FLOAT32|Time to save by 0056
0221-0222|2|Inner pipe diameter (mm)|FLOAT32|In millimeter
0229-0230|2|Upstream delay (µs)|FLOAT32|In microsecond
0231-0232|2|Downstream delay (µs)|FLOAT32|In microsecond
0233-0234|2|Calculated travel time (µs)|FLOAT32|In microsecond
0257-0288|32|LCD buffer|BCD|
0289|1|LCD buffer pointer|INT16|
0311|2|Worked time for today (s)|UINT32|Unsigned, in seconds
0313|2|Worked time for this month (s)|UINT32|Unsigned, in seconds
1437|1|Unit for flow rate|INT16|See note 5
1438|1|Unit for flow totalizer|INT16|Range 0~7,see note 1
1439|1|Multiplier for totalizer|INT16|Range 0~7,see note 1
1440|1|Multiplier for energy accumulator|INT16|Range 0~10,see note 1
1441|1|Unit for energy rate|INT16|0=GJ 1=Kcal 2=KWh,3=BTU
1442|1|Device address|INT16|
1451|2|User scale factor|FLOAT32|
1521|2|Manufacturer scale factor|FLOAT32|Read only
1529|2|Electronic serial number|BCD|High byte first
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment