Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP attempt to get serial and RS485 Modbus-RTU working #35

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
36 changes: 36 additions & 0 deletions acrel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
variant: serial
make: acrel
model: acr10r-d16te
baudrate: 9600
method: rtu
port: /dev/ttyUSB6
unit: 3
update_rate: 5
address_offset: 0
scan_batching: 1
prefix: acrel
registers:
- pub_topic: "meter"
table: holding
json_key: absorbing_active_energy
type: uint16
address: 71
pub_only_on_change: false
- pub_topic: "meter"
table: holding
json_key: releasing_active_energy
type: uint16
address: 73
pub_only_on_change: false
- pub_topic: "meter"
table: holding
json_key: inductive_reactive_energy
type: uint16
address: 75
pub_only_on_change: false
- pub_topic: "meter"
table: holding
json_key: capacitive_reactive_energy
type: uint16
address: 77
pub_only_on_change: false
7 changes: 5 additions & 2 deletions modbus4mqtt/modbus4mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ def connect(self):
self.connect_mqtt()

def connect_modbus(self):
self._mb = modbus_interface.modbus_interface(self.config['ip'],
self._mb = modbus_interface.modbus_interface(self.config.get('ip', None),
self.config.get('port', 502),
self.config.get('update_rate', 5),
variant=self.config.get('variant', None),
scan_batching=self.config.get('scan_batching', None))
unit=self.config.get('unit', 0x01),
scan_batching=self.config.get('scan_batching', None),
baudrate=self.config.get('baudrate', None),
method=self.config.get('method', None))
failed_attempts = 1
while self._mb.connect():
logging.warning("Modbus connection attempt {} failed. Retrying...".format(failed_attempts))
Expand Down
42 changes: 27 additions & 15 deletions modbus4mqtt/modbus_interface.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from time import time, sleep
import logging
from queue import Queue
from pymodbus.client.sync import ModbusTcpClient, ModbusSocketFramer
from pymodbus import exceptions
from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusSocketFramer
from SungrowModbusTcpClient import SungrowModbusTcpClient

DEFAULT_SCAN_RATE_S = 5
Expand All @@ -13,11 +12,15 @@
DEFAULT_WRITE_SLEEP_S = 0.05
DEFAULT_READ_SLEEP_S = 0.05


class modbus_interface():

def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None, scan_batching=None):
def __init__(self, ip=None, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None, scan_batching=None, method='rtu', baudrate=9600, unit=0x01):
self._ip = ip
self._port = port
self._method = method
self._baudrate = baudrate
self._unit = unit
# This is a dict of sets. Each key represents one table of modbus registers.
# At the moment it has 'input' and 'holding'
self._tables = {'input': set(), 'holding': set()}
Expand All @@ -31,10 +34,12 @@ def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None
self._scan_batching = DEFAULT_SCAN_BATCHING
if scan_batching is not None:
if scan_batching < MIN_SCAN_BATCHING:
logging.warning("Bad value for scan_batching: {}. Enforcing minimum value of {}".format(scan_batching, MIN_SCAN_BATCHING))
logging.warning("Bad value for scan_batching: {}. Enforcing minimum value of {}".format(
scan_batching, MIN_SCAN_BATCHING))
self._scan_batching = MIN_SCAN_BATCHING
elif scan_batching > MAX_SCAN_BATCHING:
logging.warning("Bad value for scan_batching: {}. Enforcing maximum value of {}".format(scan_batching, MAX_SCAN_BATCHING))
logging.warning("Bad value for scan_batching: {}. Enforcing maximum value of {}".format(
scan_batching, MAX_SCAN_BATCHING))
self._scan_batching = MAX_SCAN_BATCHING
else:
self._scan_batching = scan_batching
Expand All @@ -46,8 +51,12 @@ def connect(self):
# the modbus traffic. https://github.com/rpvelloso/Sungrow-Modbus is a drop-in
# replacement for ModbusTcpClient that manages decrypting the traffic for us.
self._mb = SungrowModbusTcpClient.SungrowModbusTcpClient(host=self._ip, port=self._port,
framer=ModbusSocketFramer, timeout=1,
RetryOnEmpty=True, retries=1)
framer=ModbusSocketFramer, timeout=1,
RetryOnEmpty=True, retries=1)
elif self._variant == 'serial':
self._mb = ModbusSerialClient(method=self._method, port=self._port, baudrate=self._baudrate,
bytesize=8, parity='N', stopbits=1,
timeout=1, retries=1)
else:
self._mb = ModbusTcpClient(self._ip, self._port,
framer=ModbusSocketFramer, timeout=1,
Expand Down Expand Up @@ -83,7 +92,8 @@ def get_value(self, table, addr):
if table not in self._values:
raise ValueError("Unsupported table type. Please only use: {}".format(self._values.keys()))
if addr not in self._values[table]:
raise ValueError("Unpolled address. Use add_monitor_register(addr, table) to add a register to the polled list.")
raise ValueError(
"Unpolled address. Use add_monitor_register(addr, table) to add a register to the polled list.")
return self._values[table][addr]

def set_value(self, table, addr, value, mask=0xFFFF):
Expand All @@ -107,7 +117,7 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
while not self._planned_writes.empty() and (time() - write_start_time) < max_block_s:
addr, value, mask = self._planned_writes.get()
if mask == 0xFFFF:
self._mb.write_register(addr, value, unit=0x01)
self._mb.write_register(addr, value, unit=self._unit)
else:
# https://pymodbus.readthedocs.io/en/latest/source/library/pymodbus.client.html?highlight=mask_write_register#pymodbus.client.common.ModbusClientMixin.mask_write_register
# https://www.mathworks.com/help/instrument/modify-the-contents-of-a-holding-register-using-a-mask-write.html
Expand All @@ -117,13 +127,13 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
# This specific read-before-write operation doesn't work on my modbus solar inverter -
# I get "Modbus Error: [Input/Output] Modbus Error: [Invalid Message] Incomplete message received, expected at least 8 bytes (0 received)"
# I suspect it's a different modbus opcode that tries to do clever things that my device doesn't support.
# result = self._mb.mask_write_register(address=addr, and_mask=(1<<16)-1-mask, or_mask=value, unit=0x01)
# result = self._mb.mask_write_register(address=addr, and_mask=(1<<16)-1-mask, or_mask=value, unit=self._unit)
# print("Result: {}".format(result))
old_value = self._scan_value_range('holding', addr, 1)[0]
and_mask = (1<<16)-1-mask
and_mask = (1 << 16)-1-mask
or_mask = value
new_value = (old_value & and_mask) | (or_mask & (mask))
self._mb.write_register(addr, new_value, unit=0x01)
self._mb.write_register(addr, new_value, unit=self._unit)
sleep(DEFAULT_WRITE_SLEEP_S)
except Exception as e:
# BUG catch only the specific exception that means pymodbus failed to write to a register
Expand All @@ -135,15 +145,16 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
def _scan_value_range(self, table, start, count):
result = None
if table == 'input':
result = self._mb.read_input_registers(start, count, unit=0x01)
result = self._mb.read_input_registers(start, count, unit=self._unit)
elif table == 'holding':
result = self._mb.read_holding_registers(start, count, unit=0x01)
result = self._mb.read_holding_registers(start, count, unit=self._unit)
try:
return result.registers
except:
# The result doesn't have a registers attribute, something has gone wrong!
raise ValueError("Failed to read {} {} table registers starting from {}: {}".format(count, table, start, result))


def _convert_from_uint16_to_type(value, type):
type = type.strip().lower()
if type == 'uint16':
Expand All @@ -154,6 +165,7 @@ def _convert_from_uint16_to_type(value, type):
return value
raise ValueError("Unrecognised type conversion attempted: uint16 to {}".format(type))


def _convert_from_type_to_uint16(value, type):
type = type.strip().lower()
if type == 'uint16':
Expand All @@ -162,4 +174,4 @@ def _convert_from_type_to_uint16(value, type):
if value < 0:
return value + 2**16
return value
raise ValueError("Unrecognised type conversion attempted: {} to uint16".format(type))
raise ValueError("Unrecognised type conversion attempted: {} to uint16".format(type))
16 changes: 16 additions & 0 deletions tests/test_modbus.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ def test_invalid_tables_and_addresses(self):
self.assertRaises(ValueError, m.add_monitor_register, 'beupe', 5)
self.assertRaises(ValueError, m.get_value, 'holding', 1000)

def test_serial_modbus(self):
with patch('modbus4mqtt.modbus_interface.ModbusSerialClient') as mock_modbus:
mock_modbus().connect.side_effect = self.connect_success
m = modbus_interface.modbus_interface(None, '/dev/ttyUSB0', variant='serial')
m.connect()

self.assertTrue(m._mb, 'Connected client')

def test_sungrow_modbus(self):
with patch('modbus4mqtt.modbus_interface.SungrowModbusTcpClient') as mock_modbus:
mock_modbus().connect.side_effect = self.connect_success
m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, variant='sungrow')
m.connect()

self.assertTrue(m._mb, 'Connected client')

def test_write_queuing(self):
with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus:
mock_modbus().connect.side_effect = self.connect_success
Expand Down