-
Notifications
You must be signed in to change notification settings - Fork 0
/
sdm630emulator
241 lines (192 loc) · 9.86 KB
/
sdm630emulator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#!/usr/bin/env python3
"""
SDM630 emulator, based on Pymodbus asynchronous server with updating task example.
The programm reads the power consumption from a local file and makes the corresponding modbus registers available.
Version: 240804
usage:
sdm630emulator [-h] [--comm {tcp,udp,serial,tls}]
[--framer {ascii,binary,rtu,socket,tls}]
[--log {critical,error,warning,info,debug}]
[--port PORT] [--store {sequential,sparse,factory,none}]
[--slaves SLAVES]
-h, --help
show this help message and exit
-c, --comm {tcp,udp,serial,tls}
set communication, default is tcp
-f, --framer {ascii,binary,rtu,socket,tls}
set framer, default depends on --comm
-l, --log {critical,error,warning,info,debug}
set log level, default is info
-p, --port PORT
set port
set serial device baud rate
--store {sequential,sparse,factory,none}
set datastore type
--slaves SLAVES
set number of slaves to respond to
examples:
sdm630emulator -c serial -p /dev/ttyUSB0 --baudrate 9600
sdm630emulator -c serial -p /dev/ttyUSB0 --baudrate 9600 -l debug
"""
import sys
import serial, time
import os, stat
from os.path import exists
from os import access, R_OK, W_OK
from datetime import datetime
from subprocess import Popen, PIPE
import subprocess
import decimal
import asyncio
import logging
import server_async
import struct
from pymodbus.constants import Endian
from collections import OrderedDict
from pymodbus.payload import (
BinaryPayloadBuilder,
)
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusServerContext,
ModbusSlaveContext,
)
from pymodbus import (
pymodbus_apply_logging_config,
)
_logger = logging.getLogger(__name__)
async def updating_task(context):
"""
Read power values from a file, check them and set the values in the modbus server.
This task runs continuously beside the server.
"""
print("updating_task: started")
# init
fc_as_hex = 4 # modbus function code: read Input Registers
slave_id = 0x02 # modbus: server ID (AKA slave ID) - We listen to this ID only! (2)
# Be aware!
# This value depends on how the inverter responds
# to the electricity meter. Growatt expects the
# Eastron SDM630 3-phase electricity meter behind modbus ID 2.
# If you change this value, please change the value in line 188!
cmd = "cat /run/power.txt" # fetch the actual power consumption from a local file
power = 0 # inital value for power
diff = 0 # initial value for time difference between the timestamp of the delivered power consumption and the local clock
voltage = 238.42 # random value around 220 to 260 volt
print("updating_task: initialised")
# never ending loop
while True:
print("updating task: Set: " + str(diff) + " -- " + str(power))
# We split the power to the three power phases in equal parts
#
builder = BinaryPayloadBuilder(wordorder=Endian.BIG,byteorder=Endian.BIG)
builder.add_32bit_float(float(voltage)) # Addr. 30001: Phase 1 line to neutral volts. --> Volt (01+02)
builder.add_32bit_float(float(voltage)) # Addr. 30003: Phase 2 line to neutral volts. --> Volt (03+04)
builder.add_32bit_float(float(voltage)) # Addr. 30005: Phase 3 line to neutral volts. --> Volt (05+06)
builder.add_32bit_float(float(power/voltage/3)) # Addr. 30007: Phase 1 current. --> Ampere (07+08)
builder.add_32bit_float(float(power/voltage/3)) # Addr. 30009: Phase 2 current. --> Ampere (09+10)
builder.add_32bit_float(float(power/voltage/3)) # Addr. 30011: Phase 3 current. --> Ampere (11+12)
builder.add_32bit_float(float(power/3)) # Addr. 30013: Phase 1 power. --> Watt (13+14)
builder.add_32bit_float(float(power/3)) # Addr. 30015: Phase 2 power. --> Watt (15+16)
builder.add_32bit_float(float(power/3)) # Addr. 30017: Phase 3 power. --> Watt (17+18)
# build the registers and write them to the storage
payload = builder.to_registers()
context[slave_id].setValues(fc_as_hex, 0, payload)
# Be aware! Some inverters (f.e. the Growatt SPH3000) check the power meter and
# correct the power output several times per second.
# If you can't keep up with the correct power values, the inverter will overcorrect
# the power feed.
#
# Solution: Keep the power values for a short amount of time (around 0.5sec).
# Inverter should have adapted. Now set the power consumption to "0" to avoid overcorrection.
await asyncio.sleep(0.5)
# print("updating task: avoid overcorrecting.")
power = 0
builder = BinaryPayloadBuilder(wordorder=Endian.BIG,byteorder=Endian.BIG) # Be aware! These settings are is very important. If you set the word- and byteorder wrong, the values will be misinterpreted.
builder.add_32bit_float(float(238.42)) # Addr. 30001: Phase 1 line to neutral volts. --> Volt
builder.add_32bit_float(float(238.42)) # Addr. 30003: Phase 2 line to neutral volts. --> Volt
builder.add_32bit_float(float(238.42)) # Addr. 30005: Phase 3 line to neutral volts. --> Volt
builder.add_32bit_float(0) # Addr. 30007: Phase 1 current. --> Ampere
builder.add_32bit_float(0) # Addr. 30009: Phase 2 current. --> Ampere
builder.add_32bit_float(0) # Addr. 30011: Phase 3 current. --> Ampere
builder.add_32bit_float(0) # Addr. 30013: Phase 1 power. --> Watt
builder.add_32bit_float(0) # Addr. 30015: Phase 2 power. --> Watt
builder.add_32bit_float(0) # Addr. 30017: Phase 3 power. --> Watt
payload = builder.to_registers()
context[slave_id].setValues(fc_as_hex, 0, payload)
# Update variable "power" from file /run/power.txt
# Format of this file is:
# <UNIX timestamp>:<power consumption in whole watts (integer value)>
try:
# Read the power consumption from a file (via external programm cat)
ps = subprocess.Popen(cmd,shell=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
await asyncio.sleep(0.5)
m_output = ps.communicate()[0]
m_string = m_output.decode("utf-8")
except subprocess.CalledProcessError as e:
print("updating_task: ERROR -- fetching power returned a non-zero exitcode.")
except subprocess.TimeoutExpired as e:
print("updating_task: ERROR -- fetching power timeout.")
else:
# We read a value from file '/run/power.txt'. Let's check the format and other things.
# Check: The value we fetched should look like this: "3456787:-34"
# (UNIX timestamp + ":" + power consumption as an integer).
m_list = m_string.split(":")
if len(m_list) != 2:
print("updating_task: ERROR -- Format wrong: " + m_string)
continue
m_power_str = m_list[1]
m_tstamp_str = m_list[0]
# Check: is m_power_str an integer?
try:
m_power = int(m_power_str)
except ValueError:
print("updating_task: ERROR -- m_power is not an integer.")
continue
# Check: is m_tstamp_str an integer?
try:
m_tstamp = int(m_tstamp_str)
except ValueError:
print("updating_task: ERROR -- m_tstamp is not an integer.")
continue
# Check: is m_power_str older than 5sec?
diff = int(round(datetime.now().timestamp()) - m_tstamp)
if diff > 5:
print("updating_task: ERROR -- value is too old.")
continue
# Everything ok. We use the value we read from the file.
power = m_power
#
# The stuff below is from pymodbus...
# || || ||
# \/ \/ \/
def setup_updating_server(cmdline=None):
"""Run server setup."""
# The datastores only respond to the addresses that are initialized
# If you initialize a DataBlock to addresses of 0x00 to 0xFF, a request to
# 0x100 will respond with an invalid address exception.
# This is because many devices exhibit this kind of behavior (but not all)
# Continuing, use a sequential block without gaps.
datablock = ModbusSequentialDataBlock(0x00, [17] * 100)
# Our ID is 0x02. We answer request to this ID only (and ignore the rest)
slaves = {
0x02: ModbusSlaveContext(di=datablock, co=datablock, hr=datablock, ir=datablock),
}
context = ModbusServerContext(slaves=slaves, single=False)
return server_async.setup_server(
description="Run asynchronous server.", context=context, cmdline=cmdline
)
async def run_updating_server(args):
"""Start updating_task concurrently with the current task."""
# pymodbus_apply_logging_config("DEBUG","/var/log/mbus.log")
task = asyncio.create_task(updating_task(args.context))
task.set_name("example updating task")
await server_async.run_async_server(args) # start the server
task.cancel()
async def main(cmdline=None):
"""Combine setup and run."""
run_args = setup_updating_server(cmdline=cmdline)
await run_updating_server(run_args)
if __name__ == "__main__":
asyncio.run(main(), debug=True)
# this is the last line :-)