Skip to content

Commit

Permalink
Keep-alive and PVOutput (PVOutput not yet tested).
Browse files Browse the repository at this point in the history
  • Loading branch information
mhvis committed Jun 24, 2020
1 parent 99b0f04 commit c07c316
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 131 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ For documentation you will need to read through the source code.
Example to get started:

```python
from samil.inverter import InverterListener
from samil.inverter import Inverter, InverterFinder

with InverterListener() as listener:
with InverterFinder() as finder:
# Search for an inverter
inverter = listener.accept_inverter()
inverter = Inverter(*finder.find_inverter())

with inverter:
# Use with statement to automatically close the connection after use
Expand Down
115 changes: 52 additions & 63 deletions samil/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
import json
import logging
import sys
from contextlib import ExitStack
from decimal import Decimal
from time import time, sleep
from typing import Iterable, List
from typing import List

import click
from paho.mqtt.client import Client as MQTTClient

from samil.inverter import InverterNotFoundError, InverterListener


from samil.inverter import InverterNotFoundError, InverterFinder, Inverter
# @click.group(context_settings={"help_option_names": ["-h", "--help"]})
from samil.util import create_inverter_listener, connect_to_inverters
from samil.pvoutput import add_status
from samil.util import connect_to_inverters, get_bound_inverter_finder


@click.group()
Expand Down Expand Up @@ -95,7 +95,7 @@ def _format_status(d):
t = [(form[0], '{}{}{}'.format(v, ' ' if form[1] else '', form[1])) for form, v in t]
return _format_two_tuple(t)

with InverterListener(interface_ip=interface or '') as listener:
with InverterFinder(interface_ip=interface or '') as listener:
print("Searching for inverter")
try:
inverter = listener.accept_inverter()
Expand Down Expand Up @@ -161,16 +161,15 @@ def mqtt(inverters, interval, host, port, client_id, tls: bool, username, passwo
"grid_voltage":242.6,"grid_current":3.6,"grid_frequency":50.01,
"internal_temperature":35.0}
"""
# Todo: add example message to docstring.
if interval > 20:
# Todo
raise ValueError("Interval of more than 20 seconds is not yet supported (requires keep-alive messages).")
# First search and connect to inverter(s)
inverter_configs = []
with InverterListener(interface_ip=interface or '') as listener:
with InverterFinder(interface_ip=interface or '') as finder:
print("Connecting to {} inverter(s)".format(inverters))
for i in range(inverters):
inverter = listener.accept_inverter()
inverter = Inverter(*finder.find_inverter())
serial_number = inverter.model()["serial_number"]
topic = "{}/{}/status".format(topic_prefix, serial_number)
print("Connected to inverter {} on IP {}".format(serial_number, inverter.addr))
Expand Down Expand Up @@ -244,67 +243,57 @@ def pvoutput(system_id, api_key, interval: int, interface, n: int, ip: List[str]
if logging.root.level > logging.INFO:
logging.basicConfig(level=logging.INFO)

# Determine correct nr of inverters
# Determine nr of inverters
count = n if n else len(ip) if ip else len(serial) if serial else 1

# Determine filter function
def keep_ip(inverter): return inverter.addr[0] in ip
def keep_serial(inverter): return inverter.model()["serial_number"] in serial
def keep_ip(inv):
return inv.addr[0] in ip

def keep_serial(inv):
return inv.model()["serial_number"] in serial

keep = keep_ip if ip else keep_serial if serial else None

# Connect to inverters
listener = create_inverter_listener(interface_ip=interface)
with listener:
with get_bound_inverter_finder(interface_ip=interface) as finder:
logging.info("Searching for inverters")
inverters = connect_to_inverters(listener, count, keep)
sleep(1000)


# try:
#
# with InverterListener(interface_ip=interface) as listener:
# inverter = listener.accept_inverter()
pass


# def pvoutput(args):
# # Search for the right inverter
# with InverterListener(interface_ip=args.interface) as listener:
# selected_inverters = []
# ignored_inverters = []
# while True:
# inverter = listener.accept_inverter()
# if not inverter:
# raise ConnectionError('Could not find inverter')
# model = inverter.model()
# logging.info('Inverter serial number: %s', model['serial_number'])
# # Check if inverter is selected
# selected = True
# if args.serial_number and model['serial_number'] not in args.serial_number:
# selected = False
# if args.ip and inverter.addr[0] not in args.ip:
# selected = False
# logging.info('Inverter is %s', 'selected' if selected else 'ignored')
# if selected:
# selected_inverters.append(inverter)
# else:
# ignored_inverters.append(inverter)
inverters = connect_to_inverters(finder, count, keep)

# Use ExitStack to make sure that each inverter is properly closed
with ExitStack() as stack:
for inverter in inverters:
stack.enter_context(inverter)

def upload():
"""Uploads status to PVOutput."""
# Todo: this should be asynchronous so that multiple inverters can be requested at once
statuses = [inv.status() for inv in inverters]
# Filter systems with normal operating mode
statuses = [s for s in statuses if s["operating_mode"] == "Normal"]
if not statuses:
logging.info("Not uploading, no inverter has operating mode normal.")
return
# Todo: check status types
add_status(system_id,
api_key,
energy_gen=sum(s["energy_today"] for s in statuses).scaleb(3),
power_gen=sum(s["output_power"] for s in statuses),
temp=sum(s["internal_temperature"] for s in statuses) / len(statuses),
voltage=sum(s["grid_voltage"] for s in statuses) / len(statuses))

if not interval:
# No interval specified, upload once and stop
upload()
return

# Interval given, run periodically
while True:
# Sleep until next boundary
timestamp = time()
sleep(timestamp + interval * 60 - timestamp % (interval * 60))
upload()

#
# # PVOutput
# parser_pvoutput = subparsers.add_parser('pvoutput', help='upload status data to PVOutput',
# description='Upload status data to PVOutput.')
# parser_pvoutput.add_argument('-i', '--interface', help='bind interface IP (default: all interfaces)', default='')
# parser_pvoutput.add_argument('-n', '--inverters', type=int, default=1,
# help='number of inverters (default: %(default)s)', dest='num')
# matcher_group = parser_pvoutput.add_mutually_exclusive_group()
# matcher_group.add_argument('--only-serial', nargs='*', dest='serial_number',
# help='only match inverters with one of the given serial numbers')
# matcher_group.add_argument('--only-ip', nargs='*', dest='ip',
# help='only match inverters with one of the given IPs')
# parser_pvoutput.add_argument('-s', '--system', type=int, help='PVOutput system ID')
# parser_pvoutput.add_argument('-k', '--api-key', type=int, help='PVOutput system API key')
# parser_pvoutput.set_defaults(func=pvoutput)
#
# # History
# parser_history = subparsers.add_parser('history', help='fetch historical generation data from inverter',
# description='Fetch historical generation data from inverter.')
Expand Down
102 changes: 68 additions & 34 deletions samil/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
from collections import OrderedDict
from socket import socket, AF_INET, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR, SOCK_DGRAM, SO_BROADCAST, timeout, SHUT_RDWR
from typing import Tuple, Dict, BinaryIO
from typing import Tuple, Dict, BinaryIO, Any

from samil.statustypes import status_types

Expand All @@ -19,6 +19,8 @@ class Inverter:
The request methods are synchronous and return the response. When the
connection is lost an exception is raised on the next time that a request
is made.
Methods are not thread-safe.
"""

# Caches the format for inverter status messages
Expand All @@ -36,23 +38,27 @@ def __init__(self, sock: socket, addr):
self.addr = addr

def __enter__(self):
"""Returns self."""
return self

def __exit__(self, *args):
"""See self.disconnect."""
self.disconnect()

def disconnect(self):
def disconnect(self) -> None:
"""Sends a disconnect message and closes the connection.
By using socket.shutdown it appears that the inverter directly will
accepts new connections.
"""
# The socket might have been closed already for some reason, in which
# case shutdown will throw OSError 107.
try:
self.sock.shutdown(SHUT_RDWR)
except OSError as e:
if e.errno != 107:
# The socket might have been closed already for some reason, in
# which case some OSError will be thrown:
#
# * [Errno 9] Bad file descriptor
if e.errno != 107 and e.errno != 9:
raise e
self.sock_file.close()
self.sock.close()
Expand Down Expand Up @@ -121,32 +127,34 @@ def history(self, start, end):
"""Requests historical data from the inverter."""
raise NotImplementedError('Not yet implemented')

def request(self, identifier: bytes, payload: bytes, response_identifier=b"") -> Tuple[bytes, bytes]:
def request(self, identifier: bytes, payload: bytes, expected_response_id=b"") -> Tuple[bytes, bytes]:
"""Sends a message and returns the received response.
Args:
identifier: The message identifier (header).
payload: The message payload.
response_identifier: Messages with a response identifier that does
not start with the value given here are ignored. By default, no
messages are ignored, so the first new message is returned.
expected_response_id: The response identifier is checked to see
whether it starts with the value given here. If it does not, an
exception is raised.
Returns:
A tuple with identifier and payload.
"""
self.send(identifier, payload)
response_id_actual, response_payload = self.receive()
while not response_id_actual.startswith(response_identifier):
logging.warning('Unexpected response (%s, %s) for request %s, retrying',
response_id_actual.hex(), response_payload.hex(), identifier.hex())
response_id_actual, response_payload = self.receive()
return response_id_actual, response_payload
response_id, response_payload = self.receive()
if not response_id.startswith(expected_response_id):
raise RuntimeError("Request failed, got unexpected inverter response {}, {} for request {}, {}".format(
response_id.hex(), response_payload.hex(), identifier.hex(), payload.hex()
))
return response_id, response_payload

def send(self, identifier: bytes, payload: bytes):
"""Constructs and sends a message to the inverter.
Raises:
BrokenPipeError: When the connection is closed.
ValueError: When the connection was already closed, with a message
'write to closed file'.
"""
message = construct_message(identifier, payload)
logging.debug('Sending %s', message.hex())
Expand All @@ -161,39 +169,67 @@ def receive(self) -> Tuple[bytes, bytes]:
return read_message(self.sock_file)


class InverterListener(socket):
"""Listener for new inverter connections."""
class InverterFinder:
"""Class for establishing new inverter connections.
Use in a 'with' statement (for the listener socket).
"""

def __init__(self, interface_ip='', **kwargs):
"""Creates listener socket for the incoming inverter connections.
def __init__(self, interface_ip=''):
"""Create instance.
Args:
interface_ip: Bind interface IP.
**kwargs: Will be passed on to socket.
interface_ip: Bind interface IP for listener and broadcast sockets.
"""
super().__init__(AF_INET, SOCK_STREAM, **kwargs)
self.interface_ip = interface_ip
# Allow socket bind conflicts
self.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
self.bind((interface_ip, 1200))
self.listen(5)
self.listen_sock = socket(AF_INET, SOCK_STREAM)
# Allow socket bind conflicts.
# This makes it possible to directly rebind to the same port.
self.listen_sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
self._listening = False

def __enter__(self):
"""See listen method."""
self.listen()
return self

def __exit__(self, *args):
"""See close method."""
self.close()

def accept_inverter(self, advertisements=10, interval=5.0) -> Inverter:
def listen(self):
"""Binds the listener socket and starts listening.
Needs to be called before searching for inverters. Use 'with' statement
to have this called automatically.
"""
if not self._listening:
self.listen_sock.bind((self.interface_ip, 1200))
self.listen_sock.listen()
self._listening = True

def close(self):
"""Closes the listener socket."""
self.listen_sock.close()

def find_inverter(self, advertisements=10, interval=5.0) -> Tuple[socket, Any]:
"""Searches for an inverter on the network.
Args:
interval: Time between each search message/advertisement.
advertisements: Number of advertisement messages to send.
Returns:
The first inverter that is found.
A tuple with the inverter socket and address, the same as what
socket.accept() returns. Can be used to construct an Inverter
instance.
Raises:
InverterNotFoundError: When no inverter was found after all search
messages have been sent.
"""
message = construct_message(b'\x00\x40\x02', b'I AM SERVER')
self.settimeout(interval)
self.listen_sock.settimeout(interval)
# Broadcast socket
with socket(AF_INET, SOCK_DGRAM) as bc:
bc.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
Expand All @@ -203,12 +239,11 @@ def accept_inverter(self, advertisements=10, interval=5.0) -> Inverter:
logging.debug('Sending server broadcast message')
bc.sendto(message, ('<broadcast>', 1300))
try:
sock, addr = self.accept()
sock, addr = self.listen_sock.accept()
logging.info('Connected with inverter on address %s', addr)
return sock, addr
except timeout:
pass
else:
logging.info('Connected with inverter on address %s', addr)
return Inverter(sock, addr)
raise InverterNotFoundError


Expand Down Expand Up @@ -274,7 +309,6 @@ def read_message(stream: BinaryIO) -> Tuple[bytes, bytes]:
return identifier, payload



class InverterNotFoundError(Exception):
"""No inverter was found on the network."""

Expand Down
Loading

0 comments on commit c07c316

Please sign in to comment.