summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--inverter0/Kconfig33
-rw-r--r--inverter0/influxdb_udp_inserter.py148
-rw-r--r--inverter0/inverter0.js18
-rw-r--r--inverter0/main.py196
-rwxr-xr-xinverter0/make.sh2
5 files changed, 318 insertions, 79 deletions
diff --git a/inverter0/Kconfig b/inverter0/Kconfig
index 144d240..eb9a5a4 100644
--- a/inverter0/Kconfig
+++ b/inverter0/Kconfig
@@ -1,5 +1,34 @@
mainmenu "configuration"
-config GUARD_SECRET
- string "Secret path component for POST"
+config UDP_HOST
+ string "UDP server hostname"
+config UDP_PORT
+ int "UDP server port"
+
+config SECRET
+ string "Python list of secret byte-sequence for auth"
+ default "[1,2,312,23,212,12]"
+
+menu "Inverter Address"
+config INVERTER_NETWORK
+ hex "Inverter network id"
+ default 0x01
+config INVERTER_SUBNET
+ hex "Inverter subnet id"
+ default 0x01
+config INVERTER_ADDRESS
+ hex "Inverter address"
+endmenu
+
+menu "Source Address"
+config SOURCE_NETWORK
+ hex "Source network id"
+ default 0x01
+config SOURCE_SUBNET
+ hex "Source subnet id"
+ default 0x01
+config SOURCE_ADDRESS
+ hex "Source Address"
+ default 0x02
+endmenu
diff --git a/inverter0/influxdb_udp_inserter.py b/inverter0/influxdb_udp_inserter.py
new file mode 100644
index 0000000..9c51951
--- /dev/null
+++ b/inverter0/influxdb_udp_inserter.py
@@ -0,0 +1,148 @@
+try:
+ import struct
+except ImportError:
+ import ustruct as struct
+
+try:
+ import socket
+except ImportError:
+ import usocket as socket
+
+
+class Struct:
+ def __init__(self, fmt):
+ self.fmt = fmt
+ self.size = struct.calcsize(self.fmt)
+
+ def pack(self, value):
+ return struct.pack(self.fmt, value)
+
+ def unpack(self, data):
+ return struct.unpack(self.fmt, data)[0]
+
+
+UINT8 = Struct('B')
+UINT16 = Struct('H')
+UINT32 = Struct('I')
+
+try:
+ import hashlib
+except ImportError:
+ import uhashlib as hashlib
+
+
+class SerializerFactory:
+ def __init__(self):
+ self.message_formats = {}
+
+ def add_message_format(self, description):
+ if not 'identifier' in description:
+ raise Exception('Missing required option "identifier"')
+ identifier = description['identifier']
+ if not isinstance(identifier, bytes):
+ identifier = bytes(identifier)
+ if identifier in self.message_formats:
+ raise Exception('There is already a message defined with identifier', description['identifier'])
+ self.message_formats[identifier] = description
+
+ def get_serializer(self, identifier):
+ if not isinstance(identifier, bytes):
+ identifier = bytes(identifier)
+ if identifier in self.message_formats:
+ return MessageSerializer.from_config(self.message_formats[identifier])
+ else:
+ raise Exception('No message format with identifier', identifier)
+
+
+class MessageSerializer:
+ @staticmethod
+ def _make_fields(fields):
+ for field_name, *values in fields:
+ v = []
+ for name, value in values:
+ if value.lower() == 'uint16':
+ v += [(name, UINT16)]
+ elif value.lower() == 'uint32':
+ v += [(name, UINT32)]
+ elif value.lower() == 'uint8':
+ v += [(name, UINT8)]
+ else:
+ raise Exception('Datatype not supported', value)
+ result = [field_name] + v
+ yield result
+
+ @staticmethod
+ def from_config(config):
+ identifier = bytes(config['identifier'])
+ secret = bytes(config['secret'])
+ database = config['database']
+ fields = list(MessageSerializer._make_fields(config['fields']))
+ return MessageSerializer(identifier, database, secret, fields)
+
+ def __init__(self, identifier, database, secret, fields):
+ self.identifier = identifier
+ self.database = database
+ self.secret = secret
+ self.fields = fields
+ self.size = sum(map(lambda f: sum(v[1].size for v in f[1:]), self.fields))
+
+ def serialize(self, data: dict):
+ if len(data) != len(self.fields):
+ raise Exception()
+ if set(data.keys()) != set(map(lambda v: v[0], self.fields)):
+ raise Exception("Data does not match schema")
+
+ payload = bytes()
+
+ for config_name, *config_values in self.fields:
+ data_values = data[config_name]
+ if set(data_values.keys()) != set(map(lambda kv: kv[0], config_values)):
+ raise Exception("inconsistent data for ", config_name, data_values.keys(), dict(config_values).keys())
+
+ for config_sub_name, config_struct in config_values:
+ payload += config_struct.pack(data_values[config_sub_name])
+
+ buf = bytes(self.identifier[0:3]) + payload
+
+ hash = hashlib.sha256(buf + self.secret).digest()
+ buf += hash[0:6]
+
+ return buf
+
+ def deserialize(self, data):
+ if len(data) < 3 + 6:
+ raise Exception('Message of wrong size', len(data))
+
+ hash = hashlib.sha256(data[:-6] + self.secret).digest()
+ if hash[0:6] != data[-6:]:
+ raise Exception("Failed to authenticate message")
+
+ payload = data[3:-6]
+ if len(payload) != self.size:
+ raise Exception('Message of wrong payload size', len(payload))
+
+ result = []
+ i = 0
+ for config_name, *config_values in self.fields:
+ result_field = {}
+
+ for config_sub_name, config_struct in config_values:
+ window = payload[i:i + config_struct.size]
+ result_field[config_sub_name] = config_struct.unpack(window)
+ i += config_struct.size
+
+ result += [(config_name, result_field)]
+
+ return dict(result)
+
+
+def send(host: str, port: int, serializer: MessageSerializer, data: dict):
+ serialized_data = serializer.serialize(data)
+
+ sockaddr = socket.getaddrinfo(host, port)[0][-1]
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ s.connect(sockaddr)
+ s.send(serialized_data)
+ finally:
+ s.close()
diff --git a/inverter0/inverter0.js b/inverter0/inverter0.js
new file mode 100644
index 0000000..9889f67
--- /dev/null
+++ b/inverter0/inverter0.js
@@ -0,0 +1,18 @@
+{
+ "identifier": [1, 1, 1],
+ "secret": null, /* set later */
+ "database": "data",
+ "fields": [
+ ["inverter0.PVVoltage1", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.PVVoltage2", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.PVVoltage3", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.PVCurrent1", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.PVCurrent2", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.PVCurrent3", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.GridVoltage", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.GridFrequency", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.SmoothedInstantEnergyProduction", ["min", "uint16"], ["max", "uint16"], ["last","uint16"]],
+ ["inverter0.LatestEvent", ["value","uint16"]],
+ ["inverter0.LatestEventModule", ["value","uint16"]]
+ ]
+}
diff --git a/inverter0/main.py b/inverter0/main.py
index 3dfe996..a4aee84 100644
--- a/inverter0/main.py
+++ b/inverter0/main.py
@@ -1,4 +1,9 @@
import machine
+import ujson
+import time
+
+import comlynx
+import influxdb_udp_inserter as iui
# RS-485 / comlynx
u1 = machine.UART(1)
@@ -8,38 +13,51 @@ u1.init(19200)
de = machine.Pin(32, machine.Pin.OUT)
de.value(0) # disable driver
-# Neoway M590
+# SIM900 Module
+# Baud settings:
+# AT+IPR=19200 ---- AT&W
u2 = machine.UART(2)
-u2.init(115200)
+u2.init(19200)
-import comlynx
-import time
+SOURCE_ADDRESS = comlynx.Address(
+ int(config['CONFIG_SOURCE_NETWORK']),
+ int(config['CONFIG_SOURCE_SUBNET']),
+ int(config['CONFIG_SOURCE_ADDRESS']))
+INVERTER_ADDRESS = comlynx.Address(
+ int(config['CONFIG_INVERTER_NETWORK']),
+ int(config['CONFIG_INVERTER_SUBNET']),
+ int(config['CONFIG_INVERTER_ADDRESS']))
+
+ping = comlynx.PingRequest(comlynx.Address(0,0,2), comlynx.Address.broadcast())
-SOURCE_ADDRESS = comlynx.Address(0x01, 0x01, 0x02)
-INVERTER_ADDRESS = comlynx.Address(0x01, 0x01, 0x3b) #3d
+class NoResponseException(Exception):
+ pass
-def write(data):
+def rs485_write(data):
try:
- de.value(1) # enable write
+ de.value(1) # enable write (Driver Enable)
u1.write(data)
- time.sleep_ms(int(len(data))) # very rough estimation
+ # there is no reliable flush() on esp32
+ # very rough estimation:
+ time.sleep_ms(int(len(data)))
finally:
de.value(0) # disable write
-
-ping = comlynx.PingRequest(comlynx.Address(0,0,2), comlynx.Address.broadcast())
-
-def communicate(message):
+def rs485_communicate(message):
data = bytes(tuple(message))
print("Send: {} => {}".format(message, data))
u1.read() # empty buf
- write(data)
+ rs485_write(data)
request = u1.read()
if request != data:
raise Exception("Unexpected comm error")
- time.sleep_ms(100)
- response = u1.read()
+ start = time.ticks_ms()
+ response = None
+ while response is None:
+ response = u1.read()
+ if time.ticks_ms() - start > 1000:
+ raise NoResponseException()
print("Received: {} => ".format(response), end='')
msg = comlynx.Message.unpack(response)
print(str(msg))
@@ -51,12 +69,16 @@ class Point:
self.min = None
self.max = None
self.last = None
+ self.value = None
+
def update(self, value):
self.min = min(self.min or value, value)
self.max = max(self.max or value, value)
self.last = value
+ self.value = value
+
def clear(self):
- self.min = self.max = self.last = None
+ self.min = self.max = self.last = self.value = None
values = list(map(Point, [
@@ -67,14 +89,23 @@ values = list(map(Point, [
error = None
def update():
- global error
+ global error, SOURCE_ADDRESS, INVERTER_ADDRESS
try:
for point in values:
print("Update: {}".format(point.name))
attrs = getattr(comlynx.Parameters.ULX, point.name)
message = comlynx.CanRequest(SOURCE_ADDRESS, INVERTER_ADDRESS, *attrs)
- reply = communicate(message)
- point.update(reply.value())
+ reply = None
+ for i in range(5):
+ try:
+ reply = rs485_communicate(message)
+ break
+ except NoResponseException:
+ pass
+ if reply is None:
+ raise NoResponseException()
+ else:
+ point.update(reply.value())
error = None
except Exception as e:
print("Error: {}".format(e))
@@ -84,7 +115,7 @@ def reset():
print("Error detected, reset in 30s")
time.sleep(30)
print("Reset SIM now")
- u2.write("AT+CFUN=15\r\n"); time.sleep(1); print(u2.read())
+ u2.write("AT+CFUN=1,1\r\n"); time.sleep(1); print(u2.read())
machine.reset()
@@ -98,35 +129,27 @@ def sim_command(command, expect):
data = u2.readline()
start = time.time()
- while time.time() - start < 5:
+ while time.time() - start < 10:
if data is not None:
data = data.strip()
print("< {}".format(data))
if data.startswith(expect):
- break
+ return
elif data == b"ERROR":
reset()
data = u2.readline()
+ print("Not found expected {}".format(expect))
+ reset()
ip_address = None
-def wait_connection():
- is_connected = False
- start = time.time()
- while not is_connected:
- if time.time() - start > 60:
- reset()
- u2.write(b"AT+XIIC?\r\n")
- time.sleep_ms(500)
- while u2.any() > 0:
- line = u2.readline()
- print("< {}".format(line))
- if line.startswith("+XIIC: 1"):
- print("PPP connection established")
- is_connected = True
-
def sim_connect():
- global ip_address
+ global ip_address, config
+ # RESET
+ sim_command(b"AT+CFUN=1,1", b"OK")
+ time.sleep(4)
+ print(u2.read())
+
# Connect to cellular network
sim_command(b"AT+CREG=1", b"OK")
is_registered = False
@@ -143,12 +166,23 @@ def sim_connect():
line.startswith("+CREG: 0,1") or line.startswith("+CREG: 0,5"):
print("Cell network status: registered")
is_registered = True
+ elif line.startswith("+CREG: 1,3"): # registration denied
+ reset()
- sim_command(b"AT+XISP=0", b"OK")
- sim_command(b"AT+CGDCONT=1,\"IP\",\"TM\"", b"OK")
- sim_command(b"AT+XIIC=1", b"OK")
-
- wait_connection()
+ sim_command(b"AT+CIPMUX=0", b"OK")
+ time.sleep(1) #?
+ sim_command(b"AT+CIPSTATUS", b"STATE: IP INITIAL")
+ time.sleep(5) #?
+ sim_command(b"AT+CSTT=\"TM\",\"\",\"\"", b"OK")
+ time.sleep(8) #?
+ sim_command(b"AT+CIPSTATUS", b"STATE: IP START")
+ sim_command(b"AT+CIICR", b"OK")
+ time.sleep(6) #?
+ sim_command(b"AT+CIPSTATUS", b"STATE: IP GPRSACT")
+ time.sleep(6) #?
+ u2.write(b"AT+CIFSR\r\n")
+ time.sleep(1)
+ sim_command(b"AT+CIPSTATUS", b"STATE: IP STATUS")
sim_command(b"AT", b"OK")
time.sleep(1)
@@ -157,23 +191,26 @@ def sim_connect():
while ip_address is None:
if time.time() - start > 60:
reset()
- u2.write(b"AT+DNS=\"www.localnet.cc\"\r\n")
+ u2.write(b"AT+CDNSGIP=\"{}\"\r\n".format(config['CONFIG_UDP_HOST']))
time.sleep(1)
while u2.any() > 0:
line = u2.readline().strip()
- if line.startswith(b"+DNS:") and len(line) > 8:
- ip_address = line[5:]
+ if line.startswith(b"+CDNSGIP: 1,") and len(line) > 8:
+ ip_address = line.split(b',')[2][1:-1]
+ print("Ip address resolved to: {}".format(ip_address))
print("Connect done")
-def tcp_send(data):
- CHUNK_SIZE = 512
- wait_connection()
- sim_command(b"AT+TCPSETUP=0," + ip_address + b",80", b"+TCPSETUP")
+def sim_udp_send(data):
+ global config
+ CHUNK_SIZE = 128
+ port = str(config['CONFIG_UDP_PORT']).encode()
+ sim_command(b"AT+CIPSTART=\"UDP\",\"" + ip_address + "\",\"" + port + "\"", "CONNECT OK")
+
try:
chunks = [data[i:i+CHUNK_SIZE] for i in range(0, len(data), CHUNK_SIZE)]
for chunk in chunks:
- u2.write(b"AT+TCPSEND=0," + str(len(chunk)).encode('ascii') + b"\r\n")
+ u2.write(b"AT+CIPSEND=" + str(len(chunk)).encode() + b"\r\n")
start = time.time()
while True:
if time.time() - start > 10:
@@ -183,55 +220,62 @@ def tcp_send(data):
print("< {}".format(line))
if line is not None and line.startswith('>'):
break
- elif line is not None and line.startswith('+TCPSEND:Error'):
- raise Exception("TCPSEND failed: {}".format(line))
+ elif line is not None and b'FAIL' in line:
+ raise Exception("UDPSEND failed: {}".format(line))
else:
time.sleep_ms(200)
u2.write(chunk)
- u2.write(b"\r")
+ u2.write(b"\r\n")
time.sleep(1)
print("< {}".format(u2.read()))
finally:
- sim_command(b"AT+TCPCLOSE=0", b"+TCPCLOSE")
+ sim_command(b"AT+CIPCLOSE", b"CLOSE OK")
-def post(data):
- global config
- secret = config['CONFIG_GUARD_SECRET']
- req = 'POST /guard/write/{}?db=data HTTP/1.1\r\nContent-Type: application/x-www-urlencoded\r\nContent-Length: {}\r\nHost: localnet.cc\r\n\r\n{}'.format(
- secret, len(data), data)
- tcp_send(req.encode('utf-8'))
-def send_data(post_func):
- global error
+with open('inverter0.js') as fp:
+ message_format = ujson.load(fp)
+ message_format['secret'] = eval(config['CONFIG_SECRET'])
+ serializer = iui.MessageSerializer.from_config(message_format)
+
+def serialize_and_send(data):
+ global serializer
+ data_bytes = serializer.serialize(data)
+ sim_udp_send(data_bytes)
+
+def prepare_data(post_func):
+ global serializer, error
data = ""
if error is not None:
- data += "inverter0.error value=\"{}\"".format(error.replace('"', '_'))
+ print(error)
else:
- for point in values:
- data += "inverter0.{} min={},max={},last={}\n".format(
- point.name, point.min, point.max, point.last)
- point.clear()
- post_func(data)
- error = None
+ data = {}
+ for field_name, *field_values in serializer.fields:
+ data[field_name] = {}
+ point = next(filter(lambda p: 'inverter0.' + p.name == field_name, values))
+ for name, value in field_values:
+ data[field_name][name] = getattr(point, name)
+ return post_func(data)
def loop():
- last_post = time.time() - 590
+ last_post = time.time() - 119
try:
while True:
update()
for point in values:
print("{}: last={} min={} max={}".format(point.name, point.last, point.min, point.max))
- if time.time() - last_post > 600:
+ if time.time() - last_post > 120:
try:
- send_data(post)
+ prepare_data(serialize_and_send)
last_post = time.time()
+ for point in values:
+ point.clear()
except Exception as e:
print(e)
- print("Time to next send: {}".format(time.time() - last_post * -1))
- time.sleep(30)
+ print("Time to next send: {}".format((time.time() - last_post - 120) * -1))
+ time.sleep(15)
except Exception as e:
print(str(e))
diff --git a/inverter0/make.sh b/inverter0/make.sh
index a26798d..7e027ea 100755
--- a/inverter0/make.sh
+++ b/inverter0/make.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-PY_SOURCES=".config boot.py main.py comlynx.py"
+PY_SOURCES=".config boot.py main.py comlynx.py influxdb_udp_inserter.py inverter0.js"
. ${0%/*}/../scripts/make.inc.sh