Compare commits
No commits in common. "main" and "v0.1" have entirely different histories.
3
.env
3
.env
@ -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: 1.0
|
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_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,4 +17,5 @@ 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, ...)
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
FROM python:3.13-slim
|
|
||||||
RUN useradd --create-home --shell /bin/bash solarcontrol
|
|
||||||
WORKDIR /script
|
|
||||||
COPY solarcontrol.py ./
|
|
||||||
RUN pip install --upgrade pip
|
|
||||||
USER solarcontrol
|
|
||||||
RUN pip install --user --no-cache-dir paho-mqtt python-dotenv requests
|
|
||||||
CMD ["python", "solarcontrol.py"]
|
|
53
README.md
53
README.md
@ -1,54 +1,3 @@
|
|||||||
# SolarControl
|
# SolarControl
|
||||||
|
|
||||||

|
Enforce a zero export (or whatever consumption you like) policy with an OpenDTU-controlled inverter and energy data from MQTT
|
||||||

|
|
||||||
|
|
||||||
Enforce a zero export (or whatever consumption you like) policy with an OpenDTU-controlled inverter and energy data from MQTT.
|
|
||||||
|
|
||||||
|
|
||||||
## Configuring and usage
|
|
||||||
|
|
||||||
|
|
||||||
### docker-compose
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login # login to docker hub
|
|
||||||
docker buildx create --name 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:<VERSION> -t bluefox42/solarcontrol:latest . --push
|
|
||||||
docker logout # so that credentials are no longer stored in clear but are deleted
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the terms of the GNU General Public License v3.0 or later, see [COPYING](COPYING).
|
|
@ -1,8 +0,0 @@
|
|||||||
services:
|
|
||||||
solarcontrol:
|
|
||||||
image: bluefox42/solarcontrol:latest
|
|
||||||
hostname: solarcontrol
|
|
||||||
container_name: solarcontrol
|
|
||||||
env_file: ".env"
|
|
||||||
restart: unless-stopped
|
|
||||||
stop_grace_period: 15s
|
|
@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -10,20 +9,6 @@ from threading import Thread
|
|||||||
import requests
|
import requests
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|
||||||
# let's get colorful!
|
|
||||||
class bcolors:
|
|
||||||
HEADER = '\033[95m'
|
|
||||||
OKBLUE = '\033[94m'
|
|
||||||
OKCYAN = '\033[96m'
|
|
||||||
OKGREEN = '\033[92m'
|
|
||||||
WARNING = '\033[93m'
|
|
||||||
FAIL = '\033[91m'
|
|
||||||
ENDC = '\033[0m'
|
|
||||||
BOLD = '\033[1m'
|
|
||||||
UNDERLINE = '\033[4m'
|
|
||||||
|
|
||||||
|
|
||||||
# get env vars
|
# get env vars
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
mq_broker = os.getenv('MQBROKER', None)
|
mq_broker = os.getenv('MQBROKER', None)
|
||||||
@ -44,12 +29,9 @@ 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
|
||||||
@ -57,20 +39,17 @@ 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 to the MQTT broker ({mq_broker}:{mq_port}) with result code {bcolors.ENDC}{reason_code}{bcolors.OKGREEN}.{bcolors.ENDC}")
|
print(f"Connected with result code {reason_code}")
|
||||||
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):
|
||||||
#print(f"{msg.topic}: {msg.payload}") # just for debug / development reasons
|
#print(f"{msg.topic}: {msg.payload}")
|
||||||
if msg.topic == mq_topic_p_house:
|
if msg.topic == mq_topic_p_house:
|
||||||
powers_raw["house_ts"] = time()
|
powers_raw["house_ts"] = time()
|
||||||
powers_raw["house"] = math.floor(((powers_raw["house"] + float(json.loads(msg.payload)["total_act_power"])) / 2)*10)/10
|
powers_raw["house"] = math.floor(((powers_raw["house"] + float(json.loads(msg.payload)["total_act_power"])) / 2)*10)/10
|
||||||
@ -88,14 +67,13 @@ 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()
|
||||||
while last_powers == powers_raw:
|
while last_powers == powers_raw:
|
||||||
sleep(0.2)
|
sleep(0.2)
|
||||||
if abs(powers_raw["solar_ts"] - powers_raw["house_ts"]) < maximum_data_ts_deviation and powers_raw["solar"] != None and powers_raw["house"] != None:
|
if abs(powers_raw["solar_ts"] - powers_raw["house_ts"]) < maximum_data_ts_deviation and powers_raw["solar"] != None and powers_raw["house"] != None:
|
||||||
print(f"{bcolors.OKCYAN}Current total P: {bcolors.BOLD}{(powers_raw['solar'] + powers_raw['house']):.2f}{bcolors.ENDC} | {bcolors.WARNING}Solar P: {bcolors.BOLD}{powers_raw['solar']:.2f}{bcolors.ENDC} | {bcolors.OKBLUE}House P: {bcolors.BOLD}{powers_raw['house']:.2f}{bcolors.ENDC}")
|
print(f"Current total P: {powers_raw['solar'] + powers_raw['house']} | Solar P: {powers_raw['solar']} | House P: {powers_raw['house']}")
|
||||||
powers["total"] = powers_raw["solar"] + powers_raw["house"]
|
powers["total"] = powers_raw["solar"] + powers_raw["house"]
|
||||||
powers["total_house"] = powers_raw["house"]
|
powers["total_house"] = powers_raw["house"]
|
||||||
powers["total_solar"] = powers_raw["solar"]
|
powers["total_solar"] = powers_raw["solar"]
|
||||||
@ -109,17 +87,12 @@ def threaded_solar_power_limit_setting():
|
|||||||
last_time = time()
|
last_time = time()
|
||||||
while True:
|
while True:
|
||||||
# Get current openDTU current limit status
|
# Get current openDTU current limit status
|
||||||
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 BaseException as e:
|
|
||||||
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)
|
|
||||||
continue # skip this loop pass
|
|
||||||
|
|
||||||
while(time() - powers["timestamp"] > 1): # wait until recent data is available
|
while(time() - powers["timestamp"] > 1): # wait until recent data is available
|
||||||
sleep(0.2)
|
sleep(0.2)
|
||||||
|
|
||||||
if status[opendtu_inverter_sn]["limit_set_status"] == "Ok" and powers["total"] != None and status[opendtu_inverter_sn]["max_power"] != 0:
|
if status[opendtu_inverter_sn]["limit_set_status"] == "Ok" and powers["total"] != None:
|
||||||
# calculate percentage to set the limit to
|
# calculate percentage to set the limit to
|
||||||
new_ideal_limit = math.floor(((powers["total"]-power_target) / status[opendtu_inverter_sn]["max_power"]) * limit_correction_factor * 1000)/10 # * 100 because its a percentage
|
new_ideal_limit = math.floor(((powers["total"]-power_target) / status[opendtu_inverter_sn]["max_power"]) * limit_correction_factor * 1000)/10 # * 100 because its a percentage
|
||||||
if new_ideal_limit > power_target_max: new_ideal_limit = power_target_max
|
if new_ideal_limit > power_target_max: new_ideal_limit = power_target_max
|
||||||
@ -139,29 +112,17 @@ def threaded_solar_power_limit_setting():
|
|||||||
if new_dampened_limit > power_target_max: new_dampened_limit = power_target_max
|
if new_dampened_limit > power_target_max: new_dampened_limit = power_target_max
|
||||||
if new_dampened_limit < power_target_min: new_dampened_limit = power_target_min
|
if new_dampened_limit < power_target_min: new_dampened_limit = power_target_min
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
request_failed = False
|
|
||||||
data_to_send = 'data={"serial":"'+opendtu_inverter_sn+'","limit_type":'+str(power_limit_type)+',"limit_value":'+str(new_dampened_limit)+'}'
|
data_to_send = 'data={"serial":"'+opendtu_inverter_sn+'","limit_type":'+str(power_limit_type)+',"limit_value":'+str(new_dampened_limit)+'}'
|
||||||
print(f"Setting new limit via the API: {bcolors.OKBLUE}{bcolors.BOLD}{str(new_dampened_limit)}%{bcolors.ENDC} ({bcolors.OKGREEN}previously: {bcolors.BOLD}{cur_limit}%{bcolors.ENDC} | {bcolors.WARNING}currently targeting: {bcolors.BOLD}{new_ideal_limit}%{bcolors.ENDC})... ", end="")
|
r = requests.post(opendtu_address.strip("/") + "/api/limit/config", data=data_to_send, headers={'Content-Type':'text/plain'}, auth=(opendtu_user, opendtu_pwd))
|
||||||
try:
|
print("Setting new limit over the API: " + str(new_dampened_limit) + f"% (previously: {cur_limit}% | currently targeting: {new_ideal_limit}%)... ", end="")
|
||||||
r = requests.post(opendtu_address.strip("/") + "/api/limit/config", data=data_to_send, headers={'Content-Type':'text/plain'}, auth=(opendtu_user, opendtu_pwd))
|
#print(f"Current limit: {cur_limit}, new ideal limit: {new_ideal_limit}, new dampened limit: {new_dampened_limit}")
|
||||||
except BaseException as e:
|
if json.loads(r.text)["type"] == "success":
|
||||||
request_failed = True
|
print("Success!")
|
||||||
|
else:
|
||||||
if request_failed:
|
print(f"Unsuccessful :( Code: {json.loads(r.text)['code']} ({json.loads(r.text)['type']})")
|
||||||
print(f"{bcolors.FAIL}Unsuccessful :( The http request failed somehow. Printing traceback.{bcolors.ENDC}\n{e}")
|
print(r.text) # keep and uncomment for debug reasons
|
||||||
else:
|
|
||||||
if "type" in json.loads(r.text).keys() and "code" in json.loads(r.text).keys():
|
|
||||||
if json.loads(r.text)["type"] == "success":
|
|
||||||
print(f"{bcolors.OKGREEN}Success!{bcolors.ENDC}")
|
|
||||||
else:
|
|
||||||
print(f"{bcolors.FAIL}Unsuccessful :( Code: {bcolors.BOLD}{json.loads(r.text)['code']} ({json.loads(r.text)['type']}){bcolors.ENDC}")
|
|
||||||
print(r.text) # keep and uncomment for debug reasons
|
|
||||||
else:
|
|
||||||
print(f"{bcolors.FAIL}Failed. Got some weird response from OpenDTU while trying to set the new limit. Maybe the OpenDTU API has changed?{bcolors.ENDC}\nOpenDTUs response: (needs to be in JSON and contain the keys 'code' and 'type')\n{r.text}")
|
|
||||||
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("Now the new limit would be set over the API (but DRY_RUN is either not specified or True): " + str(new_limit) + "%")
|
||||||
elif status[opendtu_inverter_sn]["max_power"] == 0:
|
|
||||||
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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user