diff options
-rw-r--r-- | inverter0/Kconfig | 33 | ||||
-rw-r--r-- | inverter0/influxdb_udp_inserter.py | 148 | ||||
-rw-r--r-- | inverter0/inverter0.js | 18 | ||||
-rw-r--r-- | inverter0/main.py | 196 | ||||
-rwxr-xr-x | inverter0/make.sh | 2 |
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 |