Skip to content

Commit 6a3c118

Browse files
icypetetjhowse
andauthored
Multi-word reads and writes (#29)
Add multi-word register type support: uint32, int32, uint64 and int64. Co-authored-by: Travis Howse <tjhowse@gmail.com>
1 parent 81102a6 commit 6a3c118

11 files changed

Lines changed: 397 additions & 98 deletions

File tree

.github/workflows/hacs_action.yml

Lines changed: 0 additions & 18 deletions
This file was deleted.

.github/workflows/hassfest.yml

Lines changed: 0 additions & 14 deletions
This file was deleted.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ update_rate: 5
5555
address_offset: 0
5656
variant: sungrow
5757
scan_batching: 100
58+
word_order: highlow
5859
```
5960
| Field name | Required | Default | Description |
6061
| ---------- | -------- | ------- | ----------- |
@@ -64,6 +65,7 @@ scan_batching: 100
6465
| address_offset | Optional | 0 | This offset is applied to every register address to accommodate different Modbus addressing systems. In many Modbus devices the first register is enumerated as 1, other times 0. See section 4.4 of the Modbus spec. |
6566
| variant | Optional | N/A | Allows variants of the ModbusTcpClient library to be used. Setting this to 'sungrow' enables support of SungrowModbusTcpClient. This library transparently decrypts the modbus comms with sungrow SH inverters running newer firmware versions. |
6667
| scan_batching | Optional | 100 | Must be between 1 and 100 inclusive. Modbus read operations are more efficient in bigger batches of contiguous registers, but different devices have different limits on the size of the batched reads. This setting can also be helpful when building a modbus register map for an uncharted device. In some modbus devices a single invalid register in a read range will fail the entire read operation. By setting `scan_batching` to `1` each register will be scanned individually. This will be very inefficient and should not be used in production as it will saturate the link with many read operations. |
68+
| word_order | Optional | 'highlow' | Must be either `highlow` or `lowhigh`. This determines how multi-word values are interpreted. `highlow` means a 32-bit number at address 1 will have its high two bytes stored in register 1, and its low two bytes stored in register 2. The default is typically correct, as modbus has a big-endian memory structure, but this is not universal. |
6769

6870
### Register settings
6971
```yaml
@@ -97,6 +99,9 @@ registers:
9799
- pub_topic: "external_temperature"
98100
address: 13015
99101
type: int16
102+
- pub_topic: "minutes_online"
103+
address: 13016
104+
type: uint32
100105
```
101106

102107
This section of the YAML lists all the modbus registers that you consider interesting.
@@ -113,4 +118,4 @@ This section of the YAML lists all the modbus registers that you consider intere
113118
| scale | Optional | 1 | After reading a value from the Modbus register it will be multiplied by this scalar before being published to MQTT. Values published on this register's `set_topic` will be divided by this scalar before being written to Modbus. |
114119
| mask | Optional | 0xFFFF | This is a 16-bit number that can be used to select a part of a Modbus register to be referenced by this register. For example a mask of `0xFF00` will map to the most significant byte of the 16-bit Modbus register at `address`. A mask of `0x0001` will reference only the least significant bit of this register. |
115120
| json_key | Optional | N/A | The value of this register will be published to its pub_topic in JSON format. E.G. `{ key: value }` Registers with a json_key specified can share a pub_topic. All registers with shared pub_topics must have a json_key specified. In this way, multiple registers can be published to the same topic in a single JSON message. If any of the registers that share a pub_topic have the retain field set that will affect the published JSON message. Conflicting retain settings are invalid. The keys will be alphabetically sorted. |
116-
| type | Optional | uint16 | The type of the value stored at the modbus address provided. Only uint16 (unsigned 16-bit integer) and int16 (signed 16-bit integer) are currently supported. |
121+
| type | Optional | uint16 | The type of the value stored at the modbus address provided. Only uint16 (unsigned 16-bit integer), int16 (signed 16-bit integer), uint32, int32, uint64 and int64 are currently supported. |

modbus4mqtt/SG8K-D.yaml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
ip: 192.168.0.xxx
2+
port: 502
3+
update_rate: 5
4+
address_offset: 0
5+
variant: sungrow
6+
scan_batching: 1
7+
registers:
8+
- pub_topic: "energy_meter_power" # Feed-in power is negative and taken-back power is positive (W)
9+
address: 5082
10+
table: 'input'
11+
type: int32
12+
- pub_topic: "output_power" #total output power kWh
13+
type: uint16
14+
address: 5000
15+
table: 'input'
16+
scale: 0.1
17+
- pub_topic: "daily_yield" #daily yield kWh
18+
address: 5002
19+
table: 'input'
20+
scale: 0.1
21+
- pub_topic: "total_yield" #Total yield kWh
22+
address: 5003
23+
table: 'input'
24+
- pub_topic: "total_running_time" #Total running time (h)
25+
address: 5005
26+
table: 'input'
27+
- pub_topic: "internal_temperature" #inverter internal temperature 0.1C
28+
address: 5007
29+
table: 'input'
30+
scale: 0.1
31+
- pub_topic: "dc_output" #dc output power (W)
32+
address: 5016
33+
table: 'input'
34+
- pub_topic: "phase_a_voltage" #Phase A Voltage (0.1V)
35+
address: 5018
36+
table: 'input'
37+
scale: 0.1
38+
- pub_topic: "phase_a_current" #Phase A Current (0.1A)
39+
address: 5021
40+
table: 'input'
41+
scale: 0.1
42+
- pub_topic: "ac_output" #AC output power, total active power (W)
43+
address: 5030
44+
table: 'input'
45+
- pub_topic: "power_factor" #Power factor (0.001)
46+
address: 5034
47+
table: 'input'
48+
scale: 0.001
49+
- pub_topic: "grid_frequency" #Grid Frequency (0.1Hz)
50+
address: 5035
51+
table: 'input'
52+
scale: 0.1
53+
- pub_topic: "device_state" #Device State (see comments below for states)
54+
address: 5037
55+
table: 'input'
56+
- pub_topic: "daily_running_time" #Daily running time (1m)
57+
address: 5112
58+
table: 'input'
59+
60+
#see https://github.com/tjhowse/modbus4mqtt/files/5732710/TI_20190704_Communication.Protocol.for.Residential.Single-phase.Grid-Connected.Inverters_V10_EN.pdf for full list of registers and details
61+

modbus4mqtt/modbus4mqtt.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,17 @@ def connect(self):
4242
self.connect_mqtt()
4343

4444
def connect_modbus(self):
45+
if self.config.get('word_order', 'highlow').lower() == 'lowhigh':
46+
word_order = modbus_interface.WordOrder.LowHigh
47+
else:
48+
word_order = modbus_interface.WordOrder.HighLow
49+
4550
self._mb = modbus_interface.modbus_interface(self.config['ip'],
4651
self.config.get('port', 502),
4752
self.config.get('update_rate', 5),
4853
variant=self.config.get('variant', None),
49-
scan_batching=self.config.get('scan_batching', None))
54+
scan_batching=self.config.get('scan_batching', None),
55+
word_order=word_order)
5056
failed_attempts = 1
5157
while self._mb.connect():
5258
logging.warning("Modbus connection attempt {} failed. Retrying...".format(failed_attempts))
@@ -59,7 +65,7 @@ def connect_modbus(self):
5965
sleep(self.modbus_reconnect_sleep_interval)
6066
# Tells the modbus interface about the registers we consider interesting.
6167
for register in self.registers:
62-
self._mb.add_monitor_register(register.get('table', 'holding'), register['address'])
68+
self._mb.add_monitor_register(register.get('table', 'holding'), register['address'], register.get('type', 'uint16'))
6369
register['value'] = None
6470

6571
def modbus_connection_failed(self):
@@ -96,16 +102,18 @@ def poll(self):
96102

97103
for register in self._get_registers_with('pub_topic'):
98104
try:
99-
value = self._mb.get_value(register.get('table', 'holding'), register['address'])
105+
value = self._mb.get_value( register.get('table', 'holding'),
106+
register['address'],
107+
register.get('type', 'uint16'))
100108
except Exception:
101109
logging.warning("Couldn't get value from register {} in table {}".format(register['address'],
102110
register.get('table', 'holding')))
103111
continue
104112
# Filter the value through the mask, if present.
105-
value &= register.get('mask', 0xFFFF)
106-
# Tweak the value according to the type.
107-
type = register.get('type', 'uint16')
108-
value = modbus_interface._convert_from_uint16_to_type(value, type)
113+
if 'mask' in register:
114+
# masks only make sense for uint
115+
if register.get('type', 'uint16') in ['uint16', 'uint32', 'uint64']:
116+
value &= register.get('mask')
109117
# Scale the value, if required.
110118
value *= register.get('scale', 1)
111119
# Clamp the number of decimal points
@@ -187,9 +195,8 @@ def _on_message(self, client, userdata, msg):
187195
"Bad/missing value_map? Topic: {}, Value: {}".format(topic, value))
188196
continue
189197
type = register.get('type', 'uint16')
190-
value = modbus_interface._convert_from_type_to_uint16(value, type)
191198
self._mb.set_value(register.get('table', 'holding'), register['address'], int(value),
192-
register.get('mask', 0xFFFF))
199+
register.get('mask', 0xFFFF), type)
193200

194201
# This throws ValueError exceptions if the imported registers are invalid
195202
@staticmethod
@@ -200,7 +207,7 @@ def _validate_registers(registers):
200207
duplicate_json_keys = {}
201208
# Key: shared pub_topics, value: set of retain values (true/false)
202209
retain_setting = {}
203-
valid_types = ['uint16', 'int16']
210+
valid_types = ['uint16', 'int16', 'uint32', 'int32', 'uint64', 'int64']
204211

205212
# Look for duplicate pub_topics
206213
for register in registers:

modbus4mqtt/modbus_interface.py

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from time import time, sleep
2+
from enum import Enum
23
import logging
34
from queue import Queue
45
from pymodbus.client.sync import ModbusTcpClient, ModbusSocketFramer
@@ -13,9 +14,13 @@
1314
DEFAULT_WRITE_SLEEP_S = 0.05
1415
DEFAULT_READ_SLEEP_S = 0.05
1516

17+
class WordOrder(Enum):
18+
HighLow = 1
19+
LowHigh = 2
20+
1621
class modbus_interface():
1722

18-
def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None, scan_batching=None):
23+
def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None, scan_batching=None, word_order=WordOrder.HighLow):
1924
self._ip = ip
2025
self._port = port
2126
# This is a dict of sets. Each key represents one table of modbus registers.
@@ -29,6 +34,7 @@ def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None
2934
self._writing = False
3035
self._variant = variant
3136
self._scan_batching = DEFAULT_SCAN_BATCHING
37+
self._word_order = word_order
3238
if scan_batching is not None:
3339
if scan_batching < MIN_SCAN_BATCHING:
3440
logging.warning("Bad value for scan_batching: {}. Enforcing minimum value of {}".format(scan_batching, MIN_SCAN_BATCHING))
@@ -53,11 +59,14 @@ def connect(self):
5359
framer=ModbusSocketFramer, timeout=1,
5460
RetryOnEmpty=True, retries=1)
5561

56-
def add_monitor_register(self, table, addr):
62+
def add_monitor_register(self, table, addr, type='uint16'):
5763
# Accepts a modbus register and table to monitor
5864
if table not in self._tables:
5965
raise ValueError("Unsupported table type. Please only use: {}".format(self._tables.keys()))
60-
self._tables[table].add(addr)
66+
# Register enough sequential addresses to fill the size of the register type.
67+
# Note: Each address provides 2 bytes of data.
68+
for i in range(type_length(type)):
69+
self._tables[table].add(addr+i)
6170

6271
def poll(self):
6372
# Polls for the values marked as interesting in self._tables.
@@ -79,19 +88,42 @@ def poll(self):
7988
start = group + self._scan_batching-1
8089
self._process_writes()
8190

82-
def get_value(self, table, addr):
91+
def get_value(self, table, addr, type='uint16'):
8392
if table not in self._values:
8493
raise ValueError("Unsupported table type. Please only use: {}".format(self._values.keys()))
8594
if addr not in self._values[table]:
8695
raise ValueError("Unpolled address. Use add_monitor_register(addr, table) to add a register to the polled list.")
87-
return self._values[table][addr]
96+
# Read sequential addresses to get enough bytes to satisfy the type of this register.
97+
# Note: Each address provides 2 bytes of data.
98+
value = bytes(0)
99+
# TODO Make HighLow LowHigh word ordering configurable for multi-register reads.
100+
type_len = type_length(type)
101+
for i in range(type_len):
102+
if self._word_order == WordOrder.HighLow:
103+
data = self._values[table][addr + i]
104+
else:
105+
data = self._values[table][addr + (type_len-i-1)]
106+
value += data.to_bytes(2,'big')
107+
value = _convert_from_bytes_to_type(value, type)
108+
return value
88109

89-
def set_value(self, table, addr, value, mask=0xFFFF):
110+
def set_value(self, table, addr, value, mask=0xFFFF, type='uint16'):
90111
if table != 'holding':
91112
# I'm not sure if this is true for all devices. I might support writing to coils later,
92113
# so leave this door open.
93114
raise ValueError("Can only set values in the holding table.")
94-
self._planned_writes.put((addr, value, mask))
115+
116+
bytes_to_write = _convert_from_type_to_bytes(value, type)
117+
# Put the bytes into _planned_writes stitched into two-byte pairs
118+
119+
type_len = type_length(type)
120+
for i in range(type_len):
121+
if self._word_order == WordOrder.HighLow:
122+
value = _convert_from_bytes_to_type(bytes_to_write[i*2:i*2+2], 'uint16')
123+
else:
124+
value = _convert_from_bytes_to_type(bytes_to_write[(type_len-i-1)*2:(type_len-i-1)*2+2], 'uint16')
125+
self._planned_writes.put((addr+i, value, mask))
126+
95127
self._process_writes()
96128

97129
def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
@@ -144,22 +176,33 @@ def _scan_value_range(self, table, start, count):
144176
# The result doesn't have a registers attribute, something has gone wrong!
145177
raise ValueError("Failed to read {} {} table registers starting from {}: {}".format(count, table, start, result))
146178

147-
def _convert_from_uint16_to_type(value, type):
179+
def type_length(type):
180+
# Return the number of addresses needed for the type.
181+
# Note: Each address provides 2 bytes of data.
182+
if type in ['int16', 'uint16']:
183+
return 1
184+
elif type in ['int32', 'uint32']:
185+
return 2
186+
elif type in ['int64', 'uint64']:
187+
return 4
188+
raise ValueError ("Unsupported type {}".format(type))
189+
190+
def type_signed(type):
191+
# Returns whether the provided type is signed
192+
if type in ['uint16', 'uint32', 'uint64']:
193+
return False
194+
elif type in ['int16', 'int32', 'int64']:
195+
return True
196+
raise ValueError ("Unsupported type {}".format(type))
197+
198+
def _convert_from_bytes_to_type(value, type):
148199
type = type.strip().lower()
149-
if type == 'uint16':
150-
return value
151-
elif type == 'int16':
152-
if value >= 2**15:
153-
return value - 2**16
154-
return value
155-
raise ValueError("Unrecognised type conversion attempted: uint16 to {}".format(type))
200+
signed = type_signed(type)
201+
return int.from_bytes(value,byteorder='big',signed=signed)
156202

157-
def _convert_from_type_to_uint16(value, type):
203+
def _convert_from_type_to_bytes(value, type):
158204
type = type.strip().lower()
159-
if type == 'uint16':
160-
return value
161-
elif type == 'int16':
162-
if value < 0:
163-
return value + 2**16
164-
return value
165-
raise ValueError("Unrecognised type conversion attempted: {} to uint16".format(type))
205+
signed = type_signed(type)
206+
# This can throw an OverflowError in various conditons. This will usually
207+
# percolate upwards and spit out an exception from on_message.
208+
return int(value).to_bytes(type_length(type)*2,byteorder='big',signed=signed)

modbus4mqtt/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = "0.5.1"
1+
version = "0.6.0"

0 commit comments

Comments
 (0)