Skip to content

Commit 5b52be2

Browse files
authored
Merge pull request #37 from HotNoob/v1.1.2
V1.1.2
2 parents 0e69f3c + 8dad782 commit 5b52be2

15 files changed

+586
-353
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ config.cfg
1111
invertermodbustomqtt.service
1212
log.txt
1313
variable_mask.txt
14+
variable_screen.txt
1415
.~lock.*
1516
protocols/*custom*
17+
classes/transports/*custom*
1618

1719
input_registry.json
1820
holding_registry.json

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ protocol_version = {{version}}
4141
v0.14 = growatt inverters 2020+
4242
sigineer_v0.11 = sigineer inverters
4343
growatt_2020_v1.24 = alt protocol for large growatt inverters - currently untested
44-
eg4_v58 = eg4 inverters ( EG4-6000XP ) - implemented but untested
44+
eg4_v58 = eg4 inverters ( EG4-6000XP ) - confirmed working
45+
srne_v3.9 = SRNE inverters - Untested
4546
hdhk_16ch_ac_module = some chinese current monitoring device :P
4647
```
4748

@@ -105,12 +106,19 @@ you can also find the original documented variable names there; to use the origi
105106
the csvs are using ";" as the delimeter, because that is what open office uses.
106107

107108
### variable_mask.txt
108-
if you want to only send/get specific variables, put them in this file. one variable per line. if list is empty all variables will be sent
109+
if you want to only send/get specific variables, put them in variable_mask.txt file. one variable per line. if list is empty all variables will be sent
109110
```
110111
variable1
111112
variable2
112113
```
113114

115+
### variable_screen.txt
116+
if you want to exclude specific variables, put them in the variable_screen.txt file. one variable per line.
117+
```
118+
variable_to_exclude
119+
variable_to_exclude2
120+
```
121+
114122
### Any ModBus RTU Device
115123
As i dive deeper into solar monitoring and general automation, i've come to the realization that ModBus RTU is the "standard" and basically... everything uses it. With how this is setup, it can be used with basically anything running ModBus RTU so long as you have the documentation.
116124

classes/protocol_settings.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,40 @@ class Data_Type(Enum):
4242
_14BIT = 214
4343
_15BIT = 215
4444
_16BIT = 216
45+
#signed bits
46+
_2SBIT = 302
47+
_3SBIT = 303
48+
_4SBIT = 304
49+
_5SBIT = 305
50+
_6SBIT = 306
51+
_7SBIT = 307
52+
_8SBIT = 308
53+
_9SBIT = 309
54+
_10SBIT = 310
55+
_11SBIT = 311
56+
_12SBIT = 312
57+
_13SBIT = 313
58+
_14SBIT = 314
59+
_15SBIT = 315
60+
_16SBIT = 316
61+
62+
#signed magnitude bits
63+
_2SMBIT = 402
64+
_3SMBIT = 403
65+
_4SMBIT = 404
66+
_5SMBIT = 405
67+
_6SMBIT = 406
68+
_7SMBIT = 407
69+
_8SMBIT = 408
70+
_9SMBIT = 409
71+
_10SMBIT = 410
72+
_11SMBIT = 411
73+
_12SMBIT = 412
74+
_13SMBIT = 413
75+
_14SMBIT = 414
76+
_15SMBIT = 415
77+
_16SMBIT = 416
78+
4579
@classmethod
4680
def fromString(cls, name : str):
4781
name = name.strip().upper()
@@ -74,7 +108,13 @@ def getSize(cls, data_type : 'Data_Type'):
74108
if data_type in sizes:
75109
return sizes[data_type]
76110

77-
if data_type.value > 200:
111+
if data_type.value > 400: #signed magnitude bits
112+
return data_type.value-400
113+
114+
if data_type.value > 300: #signed bits
115+
return data_type.value-300
116+
117+
if data_type.value > 200: #unsigned bits
78118
return data_type.value-200
79119

80120
return -1 #should never happen
@@ -172,6 +212,9 @@ class protocol_settings:
172212
transport : str
173213
settings_dir : str
174214
variable_mask : list[str]
215+
''' list of variables to allow and exclude all others '''
216+
variable_screen : list[str]
217+
''' list of variables to exclude '''
175218
registry_map : dict[Registry_Type, list[registry_map_entry]] = {}
176219
registry_map_size : dict[Registry_Type, int] = {}
177220
registry_map_ranges : dict[Registry_Type, list[tuple]] = {}
@@ -184,6 +227,7 @@ def __init__(self, protocol : str, settings_dir : str = 'protocols'):
184227
self.protocol = protocol
185228
self.settings_dir = settings_dir
186229

230+
#load variable mask
187231
self.variable_mask = []
188232
if os.path.isfile('variable_mask.txt'):
189233
with open('variable_mask.txt') as f:
@@ -193,6 +237,16 @@ def __init__(self, protocol : str, settings_dir : str = 'protocols'):
193237

194238
self.variable_mask.append(line.strip().lower())
195239

240+
#load variable screen
241+
self.variable_screen = []
242+
if os.path.isfile('variable_screen.txt'):
243+
with open('variable_screen.txt') as f:
244+
for line in f:
245+
if line[0] == '#': #skip comment
246+
continue
247+
248+
self.variable_screen.append(line.strip().lower())
249+
196250
self.load__json() #load first, so priority to json codes
197251

198252
if "transport" in self.settings:
@@ -252,13 +306,13 @@ def load__json(self, file : str = '', settings_dir : str = ''):
252306

253307
def load__registry(self, path, registry_type : Registry_Type = Registry_Type.INPUT) -> list[registry_map_entry]:
254308
registry_map : list[registry_map_entry] = []
255-
register_regex = re.compile(r'(?P<register>x?\d+)\.(b(?P<bit>x?\d{1,2})|(?P<byte>x?\d{1,2}))')
309+
register_regex = re.compile(r'(?P<register>(?:0?x[\dA-Z]+|[\d]+))\.(b(?P<bit>x?\d{1,2})|(?P<byte>x?\d{1,2}))')
256310

257311
data_type_regex = re.compile(r'(?P<datatype>\w+)\.(?P<length>\d+)')
258312

259-
range_regex = re.compile(r'(?P<reverse>r|)(?P<start>x?\d+)[\-~](?P<end>x?\d+)')
313+
range_regex = re.compile(r'(?P<reverse>r|)(?P<start>(?:0?x[\dA-Z]+|[\d]+))[\-~](?P<end>(?:0?x[\dA-Z]+|[\d]+))')
260314
ascii_value_regex = re.compile(r'(?P<regex>^\[.+\]$)')
261-
list_regex = re.compile(r'\s*(?:(?P<range_start>x?\d+)-(?P<range_end>x?\d+)|(?P<element>[^,\s][^,]*?))\s*(?:,|$)')
315+
list_regex = re.compile(r'\s*(?:(?P<range_start>(?:0?x[\dA-Z]+|[\d]+))-(?P<range_end>(?:0?x[\dA-Z]+|[\d]+))|(?P<element>[^,\s][^,]*?))\s*(?:,|$)')
262316

263317

264318
if not os.path.exists(path): #return empty is file doesnt exist.
@@ -513,7 +567,17 @@ def determine_delimiter(first_row) -> str:
513567
item.documented_name.strip().lower() not in self.variable_mask
514568
and item.variable_name.strip().lower() not in self.variable_mask
515569
):
516-
del registry_map[index]
570+
del registry_map[index]
571+
572+
#apply variable screen
573+
if self.variable_screen:
574+
for index in reversed(range(len(registry_map))):
575+
item = registry_map[index]
576+
if (
577+
item.documented_name.strip().lower() in self.variable_mask
578+
and item.variable_name.strip().lower() in self.variable_mask
579+
):
580+
del registry_map[index]
517581

518582
return registry_map
519583

@@ -616,6 +680,35 @@ def process_register_bytes(self, registry : dict[int,bytes], entry : registry_ma
616680
else:
617681
flags.append("0")
618682
value = ''.join(flags)
683+
684+
685+
elif entry.data_type.value > 400: #signed-magnitude bit types ( sign bit is the last bit instead of front )
686+
bit_size = Data_Type.getSize(entry.data_type)
687+
bit_mask = (1 << bit_size) - 1 # Create a mask for extracting X bits
688+
bit_index = entry.register_bit
689+
690+
# Check if the value is negative
691+
if (register >> bit_index) & 1:
692+
# If negative, extend the sign bit to fill out the value
693+
sign_extension = 0xFFFFFFFFFFFFFFFF << bit_size
694+
value = (register >> (bit_index + 1)) | sign_extension
695+
else:
696+
# If positive, simply extract the value using the bit mask
697+
value = (register >> bit_index) & bit_mask
698+
elif entry.data_type.value > 300: #signed bit types
699+
bit_size = Data_Type.getSize(entry.data_type)
700+
bit_mask = (1 << bit_size) - 1 # Create a mask for extracting X bits
701+
bit_index = entry.register_bit
702+
703+
# Check if the value is negative
704+
if (register >> (bit_index + bit_size - 1)) & 1:
705+
# If negative, extend the sign bit to fill out the value
706+
sign_extension = 0xFFFFFFFFFFFFFFFF << bit_size
707+
value = (register >> bit_index) | sign_extension
708+
else:
709+
# If positive, simply extract the value using the bit mask
710+
value = (register >> bit_index) & bit_mask
711+
619712
elif entry.data_type.value > 200 or entry.data_type == Data_Type.BYTE: #bit types
620713
bit_size = Data_Type.getSize(entry.data_type)
621714
bit_mask = (1 << bit_size) - 1 # Create a mask for extracting X bits

classes/transports/modbus_rtu.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pymodbus.client.sync import ModbusSerialClient
44
from .modbus_base import modbus_base
55
from configparser import SectionProxy
6+
from defs.common import find_usb_serial_port, get_usb_serial_port_info
67

78
class modbus_rtu(modbus_base):
89
port : str = "/dev/ttyUSB0"
@@ -19,6 +20,9 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings
1920
self.port = settings.get("port", "")
2021
if not self.port:
2122
raise ValueError("Port is not set")
23+
24+
self.port = find_usb_serial_port(self.port)
25+
print("Serial Port : " + self.port + " = "+get_usb_serial_port_info(self.port)) #print for config convience
2226

2327
self.baudrate = settings.getint("baudrate", 9600)
2428

classes/transports/mqtt.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class mqtt(transport_base):
3131

3232
reconnect_attempts : int = 21
3333

34-
max_precision : int = - 1
34+
#max_precision : int = - 1
3535

3636

3737
holding_register_prefix : str = ""
@@ -56,7 +56,7 @@ def __init__(self, settings : SectionProxy):
5656
self.discovery_enabled = strtobool(settings.get('discovery_enabled', self.discovery_enabled))
5757
self.json = strtobool(settings.get('json', self.json))
5858
self.reconnect_delay = settings.getint('reconnect_delay', fallback=7)
59-
self.max_precision = settings.getint('max_precision', fallback=self.max_precision)
59+
#self.max_precision = settings.getint('max_precision', fallback=self.max_precision)
6060

6161
if not isinstance( self.reconnect_delay , int) or self.reconnect_delay < 1: #minumum 1 second
6262
self.reconnect_delay = 1
@@ -170,9 +170,12 @@ def write_data(self, data : dict[str, str]):
170170
if(self.json):
171171
# Serializing json
172172
json_object = json.dumps(data, indent=4)
173-
self.client.publish(self.base_topic, json_object, 0, properties=self.__properties)
173+
self.client.publish(self.base_topic, json_object, 0, properties=self.mqtt_properties)
174174
else:
175175
for entry, val in data.items():
176+
if isinstance(val, float) and self.max_precision >= 0: #apply max_precision on mqtt transport
177+
val = round(val, self.max_precision)
178+
176179
self.client.publish(str(self.base_topic+'/'+entry).lower(), str(val))
177180

178181
def client_on_message(self, client, userdata, msg):

classes/transports/serial_pylon.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from .serial_frame_client import serial_frame_client
99
from .transport_base import transport_base
10+
from defs.common import find_usb_serial_port, get_usb_serial_port_info
1011

1112

1213

@@ -63,6 +64,9 @@ def __init__(self, settings : 'SectionProxy', protocolSettings : 'protocol_setti
6364
self.port = settings.get("port", "")
6465
if not self.port:
6566
raise ValueError("Port is not set")
67+
68+
self.port = find_usb_serial_port(self.port)
69+
print("Serial Port : " + self.port + " = "+get_usb_serial_port_info(self.port)) #print for config convience
6670

6771
self.baudrate = settings.getint("baudrate", 9600)
6872

classes/transports/transport_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class transport_base:
1919
device_model : str = 'hotnoob'
2020
bridge : str = ''
2121
write_enabled : bool = False
22+
max_precision : int = 2
2223

2324
read_interval : float = 0
2425
last_read_time : float = 0
@@ -56,10 +57,13 @@ def __init__(self, settings : 'SectionProxy', protocolSettings : 'protocol_setti
5657
self.device_name = settings.get(['device_name', 'name'], fallback=self.device_manufacturer+"_"+self.device_serial_number)
5758
self.bridge = settings.get("bridge", self.bridge)
5859
self.read_interval = settings.getfloat("read_interval", self.read_interval)
60+
self.max_precision = settings.getint(["max_precision", "precision"], self.max_precision)
5961
if "write_enabled" in settings:
6062
self.write_enabled = settings.getboolean("write_enabled", self.write_enabled)
6163
else:
6264
self.write_enabled = settings.getboolean("write", self.write_enabled)
65+
66+
6367

6468
def init_bridge(self, from_transport : 'transport_base'):
6569
pass

defs/common.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import serial.tools.list_ports
2+
import re
3+
14
def strtobool (val):
25
"""Convert a string representation of truth to true (1) or false (0).
36
True values are 'y', 'yes', 't', 'true', 'on', and '1'
@@ -13,4 +16,34 @@ def strtoint(val : str) -> int:
1316
if val and val[0] == 'x':
1417
return int.from_bytes(bytes.fromhex(val[1:]), byteorder='big')
1518

16-
return int(val)
19+
if val and val.startswith("0x"):
20+
return int.from_bytes(bytes.fromhex(val[2:]), byteorder='big')
21+
22+
if not val: #empty
23+
return 0
24+
25+
return int(val)
26+
27+
def get_usb_serial_port_info(port : str = '') -> str:
28+
for p in serial.tools.list_ports.comports():
29+
if str(p.device).upper() == port.upper():
30+
return "["+hex(p.vid)+":"+hex(p.pid)+":"+str(p.serial_number)+":"+str(p.location)+"]"
31+
32+
def find_usb_serial_port(port : str = '', vendor_id : str = '', product_id : str = '', serial_number : str = '', location : str = '') -> str:
33+
if not port.startswith('['):
34+
return port
35+
36+
match = re.match(r"\[(?P<vendor>[x\d]+|):?(?P<product>[x\d]+|):?(?P<serial>\d+|):?(?P<location>[\d\-]+|)\]", port)
37+
if match:
38+
vendor_id = int(match.group("vendor"), 16) if match.group("vendor") else ''
39+
product_id = int(match.group("product"), 16) if match.group("product") else ''
40+
serial_number = match.group("serial") if match.group("serial") else ''
41+
location = match.group("location") if match.group("location") else ''
42+
43+
for port in serial.tools.list_ports.comports():
44+
if ((not vendor_id or port.vid == vendor_id) and
45+
( not product_id or port.pid == product_id) and
46+
( not serial_number or port.serial_number == serial_number) and
47+
( not location or port.location == location)):
48+
return port.device
49+
return None

docs/SRNE_MODBUS_v3.9.pdf

1.98 MB
Binary file not shown.

protocol_gateway.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def get(self, section, option, *args, **kwargs):
7171
raise NoOptionError(option[0], section)
7272
else:
7373
value = super().get(section, option, *args, **kwargs)
74+
75+
if isinstance(value, int):
76+
return value
77+
7478
return value.strip() if value is not None else value
7579

7680
class Protocol_Gateway:

0 commit comments

Comments
 (0)