Skip to content

Commit

Permalink
Add influx and Docker
Browse files Browse the repository at this point in the history
  • Loading branch information
mhvis committed Aug 26, 2022
1 parent 3bbde9f commit f5d9147
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 21 deletions.
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Ignore entire parent folder and only include necessary files
**
!samil/**/*.py
!tests/**/*.py
!.dockerignore
!LICENSE
!README.md
!requirements.txt
!setup.py
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ exclude =
dist
samil.egg-info
resources # <-- should maybe be removed
research
setup.py

max-complexity = 10
Expand Down
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.10

ENV PYTHONUNBUFFERED=1
WORKDIR /usr/src/app

COPY requirements.txt ./

RUN pip install --no-cache-dir -r requirements.txt

# The .dockerignore file only includes necessary files/folders
COPY . .
RUN pip install --no-cache-dir .
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,35 @@ The following features are not implemented but can be easily implemented upon re
* Filter inverter based on IP or serial number
* Support for multiple PVOutput.org systems

## Installation
## Getting started

The script uses Python 3.
### Docker

##### Ubuntu/Debian/Raspberry Pi
You can run any of the available commands with Docker.
Make sure to use host networking because the app relies on UDP broadcasts.
The image is currently not built for ARM platforms like Raspberry Pi,
so in that case you need to build it yourself.

```commandline
```
docker run --network host mhvis/samil samil monitor
```

Here is a sample `compose.yaml`:

```yaml
name: "samil"

services:
samil:
image: mhvis/samil
command: samil monitor # Adapt as desired
network_mode: host
restart: unless-stopped
```
### Ubuntu/Debian/Raspberry Pi
```
$ sudo apt install python3-pip
$ pip3 install --user samil
```
Expand All @@ -46,13 +68,13 @@ If the `samil` command can't be found, first try to relogin.
If that doesn't help you need to change the `PATH` variable
with the following command and relogin to apply the change.

```commandline
```
$ echo 'PATH="$HOME/.local/bin:$PATH"' >> ~/.profile
```

##### Other
### Other

```commandline
```
$ pip install samil
```

Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
paho-mqtt==1.6.1
click==8.1.3
influxdb-client==1.32.0
78 changes: 72 additions & 6 deletions samil/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
from time import time, sleep

import click
from influxdb_client import InfluxDBClient, Point
from influxdb_client.client.write_api import SYNCHRONOUS
from paho.mqtt.client import Client as MQTTClient

from samil.inverter import InverterNotFoundError, InverterFinder, KeepAliveInverter
from samil.inverterutil import connect_inverters
from samil.pvoutput import add_status, aggregate_statuses

logger = logging.getLogger(__name__)


@click.group()
@click.option('--debug', is_flag=True, help="Enable debug output.")
Expand Down Expand Up @@ -229,25 +233,26 @@ def pvoutput(system_id, api_key, interface, n: int, dc_voltage: bool, interval:
If you don't want to use cron, specify the --interval option to
make the application upload status data on the specified interval.
With this mode the application will stay connected to the inverters
in between uploads, this is less recommended.
This mode is not recommended. The application will stay connected to the
inverters in between uploads and will crash when the connection is lost,
thus you need a restart mechanism such as systemd.
"""
# Print info messages (at least)
if logging.root.level > logging.INFO:
logging.basicConfig(level=logging.INFO)

logging.info("Connecting to inverter(s)")
logger.info("Connecting to inverter(s)")
with connect_inverters(interface, n) as inverters:
def upload():
"""Uploads status to PVOutput."""
# Todo: this should be asynchronous so that multiple inverters can be requested at once
status_data = aggregate_statuses([inv.status() for inv in inverters], dc_voltage=dc_voltage)
if not status_data:
logging.info("Not uploading, no inverter has operating mode normal")
logger.info("Not uploading, no inverter has operating mode normal")
return

# Upload
logging.info("Uploading status data: %s", status_data)
logger.info("Uploading status data: %s", status_data)
if not dry_run:
add_status(system_id, api_key, **status_data)

Expand All @@ -257,12 +262,13 @@ def upload():
return

# Interval given, run periodically
logging.info("Waiting for next interval boundary to do the first upload")
logger.info("Waiting for next interval boundary to do the first upload")
while True:
# Sleep until next boundary
sleep(interval * 60 - time() % (interval * 60))
upload()


# # History
# parser_history = subparsers.add_parser('history', help='fetch historical generation data from inverter',
# description='Fetch historical generation data from inverter.')
Expand All @@ -283,3 +289,63 @@ def upload():
# parser.print_help()
# parser.exit()
# args.func(args)


@cli.command()
@click.argument('bucket')
@click.option('-c', help="InfluxDB client configuration file.")
@click.option('--interval', default=10.0, help="Interval between status writes in seconds.", show_default=True)
@click.option('--interface', default='', help="IP address of local network interface to bind to.")
@click.option('--gzip', is_flag=True, default=False, help="Use GZip compression for the InfluxDB writes.")
@click.option('--measurement', default='samil', help="InfluxDB measurement name.", show_default=True)
def influx(bucket: str, c: str, interval: float, interface: str, gzip: bool, measurement: str):
"""Writes system status data to an InfluxDB database.
The InfluxDB instance can be specified using environment variables or a
configuration file. See
https://github.com/influxdata/influxdb-client-python#client-configuration.
Use the option -c to point to a configuration file. Specify the bucket to
write to in the BUCKET argument. Each measurement will have the name
'samil'.
Do you have multiple inverters? This command only supports 1 inverter
because I am lazy and only need 1, but if you need more, create an issue on
the GitHub project page. It is trivial to add.
This command has no built-in restart mechanism and will crash for instance
when the Influx or inverter connection is lost. (This is again because I am
lazy, use systemd or Docker to restart on failure.)
Status is not written when the inverter is powered off at night.
"""
# Print info messages (at least)
if logging.root.level > logging.INFO:
logging.basicConfig(level=logging.INFO)

if c:
client = InfluxDBClient.from_config_file(c, enable_gzip=gzip)
else:
client = InfluxDBClient.from_env_properties(enable_gzip=gzip)
write_client = client.write_api(write_options=SYNCHRONOUS)

logger.info("Connecting to inverter")
with connect_inverters(interface, 1) as inverters:
inv = inverters[0] # type: KeepAliveInverter

logger.info("Startup complete, will write every %s seconds to bucket %s with measurement name %s",
interval, bucket, measurement)
next_run = time() + interval
while True:
s = inv.status()
if s['operation_mode'] == 'PV power off':
# Do not write at night
continue

p = Point(measurement)
for k, v in s.items():
# TODO: maybe filter out zeros
p.field(k, v)
logger.debug("Writing point: %s", p)
write_client.write(bucket=bucket, record=p)
sleep(next_run - time())
next_run += interval
16 changes: 9 additions & 7 deletions samil/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from samil.statustypes import status_types

logger = logging.getLogger(__name__)


class Inverter:
"""Provides methods for communicating with a connected inverter.
Expand Down Expand Up @@ -110,8 +112,8 @@ def status(self) -> Dict:

# Payload should be twice the size of the status format
if 2 * len(self._status_format) != len(payload):
logging.warning("Size of status payload and format differs, format %s, payload %s",
self._status_format.hex(), payload.hex())
logger.warning("Size of status payload and format differs, format %s, payload %s",
self._status_format.hex(), payload.hex())

# Retrieve all status data type values
status_values = OrderedDict()
Expand Down Expand Up @@ -149,7 +151,7 @@ def request(self, identifier: bytes, payload: bytes, expected_response_id=b"") -
self.send(identifier, payload)
response_id, response_payload = self.receive()
while not response_id.startswith(expected_response_id):
logging.warning("Got unexpected inverter response {} for request {}".format(
logger.warning("Got unexpected inverter response {} for request {}".format(
response_id.hex(), identifier.hex()))
response_id, response_payload = self.receive()
return response_id, response_payload
Expand All @@ -163,7 +165,7 @@ def send(self, identifier: bytes, payload: bytes):
'write to closed file'.
"""
message = construct_message(identifier, payload)
logging.debug('Sending %s', message.hex())
logger.debug('Sending %s', message.hex())
self.sock_file.write(message)
self.sock_file.flush()

Expand Down Expand Up @@ -251,7 +253,7 @@ def open_with_retries(self, retries=10, period=1.0):
# Re-raise if the thrown error does not equal 'port already bound' (98) or its Windows variant (10048)
if e.errno != 98 and e.errno != 10048:
raise
logging.info("Listening port (1200) already in use, retrying")
logger.info("Listening port (1200) already in use, retrying")
# Check for maximum number of retries
tries += 1
if tries >= retries:
Expand Down Expand Up @@ -288,11 +290,11 @@ def find_inverter(self, advertisements=10, interval=5.0) -> Tuple[socket.socket,
bc.bind((self.interface_ip, 0))

for i in range(advertisements):
logging.debug('Sending server broadcast message')
logger.debug('Sending server broadcast message')
bc.sendto(message, ('<broadcast>', 1300))
try:
sock, addr = self.listen_sock.accept()
logging.info('Connected with inverter on address %s', addr)
logger.info('Connected with inverter on address %s', addr)
return sock, addr
except socket.timeout:
pass
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

setup(
name="samil",
version="2.1.0",
version="2.2.0",
author="Maarten Visscher",
author_email="mail@maartenvisscher.nl",
description="Samil Power inverter tool",
Expand All @@ -32,5 +32,6 @@
install_requires=[
"paho-mqtt>=1.5.0",
"click>=7.1.2",
"influxdb-client>=1.32.0",
]
)

0 comments on commit f5d9147

Please sign in to comment.