10 Commits

5 changed files with 45 additions and 16 deletions

3
.env
View File

@@ -9,7 +9,7 @@ OPENDTU_ADDR=<ADDRESS OF OPENDTU> # address of opendtu (format: http(s)://<IP-
OPENDTU_USER=<OPENDTU USER> # username for opendtu auth OPENDTU_USER=<OPENDTU USER> # username for opendtu auth
OPENDTU_PWD=<OPENDTU PWD> # password for opendtu auth OPENDTU_PWD=<OPENDTU PWD> # password for opendtu auth
OPENDTU_INVERTER_SN=<SERIAL NR> # serial number of the inverter to control OPENDTU_INVERTER_SN=<SERIAL NR> # serial number of the inverter to control
LIMIT_CORRECTION_FACTOR=2 # correction factor for limit setting (e.g.: when only 2 strings of 4 are connected, you always need to set 2x the power), default: 2 LIMIT_CORRECTION_FACTOR=2 # correction factor for limit setting (e.g.: when only 2 strings of 4 are connected, you always need to set 2x the power), default: 1.0
LIMIT_UPDATE_INTERVAL=5 # interval in which the limit shall be updated, default: 5 [sec] LIMIT_UPDATE_INTERVAL=5 # interval in which the limit shall be updated, default: 5 [sec]
DRY_RUN=0 # if the limit shall be set or not; default: 1 (0: False, 1: True) DRY_RUN=0 # if the limit shall be set or not; default: 1 (0: False, 1: True)
POWER_TARGET=15 # the target power consumption of the house, default: 50 [Watts] POWER_TARGET=15 # the target power consumption of the house, default: 50 [Watts]
@@ -17,5 +17,4 @@ POWER_TARGET_MIN=0 # minimum percentage for the inverter output
POWER_TARGET_MAX=100 # maximum percentage for the inverter output limit, default: 100.0 [%] POWER_TARGET_MAX=100 # maximum percentage for the inverter output limit, default: 100.0 [%]
POWER_DAMPING_FACTOR=0.7 # damping factor for changes of the inverter output limit (between 0-1), default: 0.3 POWER_DAMPING_FACTOR=0.7 # damping factor for changes of the inverter output limit (between 0-1), default: 0.3
POWER_LIMIT_CHANGE_TRESHOLD=0.3 # set a treshold for the api calls: they will not be executed if the new limit isn't that much higher, default: 0.5 POWER_LIMIT_CHANGE_TRESHOLD=0.3 # set a treshold for the api calls: they will not be executed if the new limit isn't that much higher, default: 0.5
POWER_LIMIT_TYPE=1 # the power limit type; DON'T CHANGE if you don't know what you're doing, default: 1 (see https://github.com/tbnobody/OpenDTU/discussions/742)
PYTHONUNBUFFERED=1 # for use in docker images (for fast logs, ...) PYTHONUNBUFFERED=1 # for use in docker images (for fast logs, ...)

View File

@@ -2,6 +2,7 @@ FROM python:3.13-slim
RUN useradd --create-home --shell /bin/bash solarcontrol RUN useradd --create-home --shell /bin/bash solarcontrol
WORKDIR /script WORKDIR /script
COPY solarcontrol.py ./ COPY solarcontrol.py ./
RUN pip install --upgrade pip && pip3 install --no-cache-dir paho-mqtt python-dotenv requests RUN pip install --upgrade pip
USER solarcontrol USER solarcontrol
ENTRYPOINT ["/usr/bin/python3", "solarcontrol.py"] RUN pip install --user --no-cache-dir paho-mqtt python-dotenv requests
CMD ["python", "solarcontrol.py"]

View File

@@ -1,17 +1,27 @@
# SolarControl # SolarControl
Enforce a zero export (or whatever consumption you like) policy with an OpenDTU-controlled inverter and energy data from MQTT ![Version badge](https://shields.privacynerd.de/badge/Latest-v1.1-green?style=flat-square)
![DockerHub pulls badge](https://shields.privacynerd.de/docker/pulls/bluefox42/solarcontrol?style=flat-square)
Enforce a zero export (or whatever consumption you like) policy with an OpenDTU-controlled inverter and energy data from MQTT.
## Configuring and usage ## Configuring and usage
### Docker ### docker-compose
Todo To run it using docker, try the `docker-compose.yaml` file present in this repository. You will need the .env file too for that reasons, so the easiest thing is to just run the following commands:
```bash
git clone https://git.privacynerd.de/BlueFox/SolarControl.git && cd SolarControl
vi .env # adjust the script to your needs
docker-compose up -d && docker-compose logs -f
```
### Bare-bone ### barebone
The script can be configured using the .env file where you can adjust it (hopefully perfect) to your needs. After that, just run the script: The script can be configured using the .env file where you can adjust it (hopefully perfect) to your needs. After that, just run the script:
@@ -22,7 +32,12 @@ python3 lge320reader.py
Please note: the .env file needs to be in the same folder or any other folder higher up in the directory structure as the script (more specifically, the WORKDIR). It is just a help, actually, the script searches for specific variables in its environment variables. It only loads the .env file so that you do not need to `export` all the files before running (see https://pypi.org/project/python-dotenv/ for more details). This also means that when using it in docker, you can set the docker containers environment file to that .env file and it will be accepted too. Please note: the .env file needs to be in the same folder or any other folder higher up in the directory structure as the script (more specifically, the WORKDIR). It is just a help, actually, the script searches for specific variables in its environment variables. It only loads the .env file so that you do not need to `export` all the files before running (see https://pypi.org/project/python-dotenv/ for more details). This also means that when using it in docker, you can set the docker containers environment file to that .env file and it will be accepted too.
## Building docker ## Updating
To update, simply use `git pull` to pull the latest changes. Afterwards, you need to restart your script (with docker, just use `docker-compose up -d --force-recreate`).
## Building the docker image
To build the image for docker, simply use the following commands: To build the image for docker, simply use the following commands:
@@ -30,7 +45,7 @@ To build the image for docker, simply use the following commands:
docker login # login to docker hub docker login # login to docker hub
docker buildx create --name buildx-multi-arch docker buildx create --name buildx-multi-arch
docker buildx use buildx-multi-arch docker buildx use buildx-multi-arch
docker buildx build --no-cache --platform linux/amd64,linux/386,linux/arm/v5,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x -t bluefox42/solarcontrol:<TAGNAME> . --push docker buildx build --no-cache --platform linux/amd64,linux/386,linux/arm/v5,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x -t bluefox42/solarcontrol:<VERSION> -t bluefox42/solarcontrol:latest . --push
``` ```

8
docker-compose.yaml Normal file
View File

@@ -0,0 +1,8 @@
services:
solarcontrol:
image: bluefox42/solarcontrol:latest
hostname: solarcontrol
container_name: solarcontrol
env_file: ".env"
restart: unless-stopped
stop_grace_period: 15s

View File

@@ -8,7 +8,6 @@ from time import time, sleep
import json import json
from threading import Thread from threading import Thread
import requests import requests
import urllib3 # only for exception handling
import math import math
@@ -45,9 +44,12 @@ power_target_min = float(os.getenv('POWER_TARGET_MIN', 0))
power_target_max = float(os.getenv('POWER_TARGET_MAX', 100)) power_target_max = float(os.getenv('POWER_TARGET_MAX', 100))
power_damping_factor = float(os.getenv('POWER_DAMPING_FACTOR', 0.3)) power_damping_factor = float(os.getenv('POWER_DAMPING_FACTOR', 0.3))
power_limit_change_treshold = float(os.getenv('POWER_LIMIT_CHANGE_TRESHOLD', 0.5)) power_limit_change_treshold = float(os.getenv('POWER_LIMIT_CHANGE_TRESHOLD', 0.5))
power_limit_type = int(os.getenv('POWER_LIMIT_TYPE', 1))
dry_run = bool(int(os.getenv('DRY_RUN', 1))) dry_run = bool(int(os.getenv('DRY_RUN', 1)))
# set other important variables
power_limit_type = 1 # only set the limit temporary to avoid memory damage; see also: https://github.com/tbnobody/OpenDTU/discussions/742
version = "v1.1" # the version number
# some checks for the correctness of supplied data # some checks for the correctness of supplied data
if power_target_min < 0: power_target_min = 0 if power_target_min < 0: power_target_min = 0
if power_target_max > 100: power_target_max = 100 if power_target_max > 100: power_target_max = 100
@@ -55,13 +57,16 @@ if power_damping_factor < 0: power_damping_factor = 0.0
if power_damping_factor > 1: power_damping_factor = 1.0 if power_damping_factor > 1: power_damping_factor = 1.0
print(f"{bcolors.OKCYAN}Welcome to {bcolors.ENDC}{bcolors.OKBLUE}{bcolors.BOLD}SolarControl{bcolors.ENDC}{bcolors.OKCYAN} ({version})!{bcolors.ENDC}")
# create the powers dict (containing the current use) and data variables (for thread sharing) # create the powers dict (containing the current use) and data variables (for thread sharing)
powers_raw = {"solar": 0, "solar_ts": 0, "house": 0, "house_ts": 0} powers_raw = {"solar": 0, "solar_ts": 0, "house": 0, "house_ts": 0}
powers = {"total": None, "total_house": None, "total_solar": None, "timestamp": 0} powers = {"total": None, "total_house": None, "total_solar": None, "timestamp": 0}
# define mqtt callbacks # define mqtt callbacks
def on_connect(client, userdata, flags, reason_code, properties): def on_connect(client, userdata, flags, reason_code, properties):
print(f"{bcolors.OKGREEN}Connected with result code {bcolors.ENDC}{reason_code}") print(f"{bcolors.OKGREEN}Connected to the MQTT broker ({mq_broker}:{mq_port}) with result code {bcolors.ENDC}{reason_code}{bcolors.OKGREEN}.{bcolors.ENDC}")
client.subscribe("lge320/#") client.subscribe("lge320/#")
client.subscribe("solar/ac/#") client.subscribe("solar/ac/#")
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
@@ -83,6 +88,7 @@ mqttc.connect(mq_broker, mq_port, 60)
mqttc.loop_start() mqttc.loop_start()
def threaded_current_power_calculation(): def threaded_current_power_calculation():
while True: while True:
last_powers = powers_raw.copy() last_powers = powers_raw.copy()
@@ -105,8 +111,8 @@ def threaded_solar_power_limit_setting():
# Get current openDTU current limit status # Get current openDTU current limit status
try: try:
status = requests.get(opendtu_address.strip("/") + "/api/limit/status", auth=(opendtu_user, opendtu_pwd)).json().copy() status = requests.get(opendtu_address.strip("/") + "/api/limit/status", auth=(opendtu_user, opendtu_pwd)).json().copy()
except (requests.exceptions.RequestException, urllib3.exceptions.HTTPError) as e: except BaseException as e:
print(f"{bcolors.ERROR}Some error occured while trying to reach out to OpenDTU to get latest data about the inverter limit status. Skipping for now.{bcolors.ENDC}\n==== START OF EXCEPTION ====\n{e}\n==== END OF EXCEPTION ====") print(f"{bcolors.FAIL}Some error occured while trying to reach out to OpenDTU to get latest data about the inverter limit status. Skipping for now.{bcolors.ENDC}\n==== START OF EXCEPTION ====\n{e}\n==== END OF EXCEPTION ====")
sleep(0.2) # wait some time (to avaid cpu overload on continuous unavailability of the service) sleep(0.2) # wait some time (to avaid cpu overload on continuous unavailability of the service)
continue # skip this loop pass continue # skip this loop pass
@@ -155,7 +161,7 @@ def threaded_solar_power_limit_setting():
else: else:
print(f"{bcolors.WARNING}Now the new limit would be set via the API (but DRY_RUN is either not specified or True): {str(new_limit)}%") print(f"{bcolors.WARNING}Now the new limit would be set via the API (but DRY_RUN is either not specified or True): {str(new_limit)}%")
elif status[opendtu_inverter_sn]["max_power"] == 0: elif status[opendtu_inverter_sn]["max_power"] == 0:
print(f"{bcolors.ERROR}OpenDTU is reporting strange values for the inverter's maximum power output. Skipping it for now.{bcolors.ENDC}") print(f"{bcolors.FAIL}OpenDTU is reporting strange values for the inverter's maximum power output. Skipping it for now.{bcolors.ENDC}")
while (time() - last_time) < limit_update_interval: while (time() - last_time) < limit_update_interval: