Compare commits
2
Commits
f206365b9d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
651676afda | ||
|
|
0b17034b68 |
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# chargeman
|
||||||
|
|
||||||
|
Home battery controller for Victron (Venus OS). Controls the inverter via D-Bus (AC power setpoint, feed-in/charge enable),
|
||||||
|
uses P1 meter data and the Tibber hourly price via MQTT, and publishes state to MQTT (Home Assistant discovery).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
- Install on the Venus OS device under `/data/chargeman`, copy `config.ini.example` to `config.ini` and set the Tibber key.
|
||||||
|
- Init script: `chargeman` (`start|stop|restart|status`), runs `chargeman.py` with `/data` as working directory.
|
||||||
|
- MQTT broker: env `CHARGEMAN_MQTT_BROKER` (default `10.2.0.15`) and `CHARGEMAN_MQTT_PORT` (default `1883`).
|
||||||
|
|
||||||
|
## Chargemodes (`avr95/battery/chargemode/set`)
|
||||||
|
0 floating, 1 charge manual, 2 charge max, 3 discharge manual, 4 discharge max, 5 charge cheap (Tibber), 6 balance dyn, 7 charge dyn, 8 discharge dyn.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
|
||||||
|
# Quick start-stop-daemon example, derived from Debian /etc/init.d/ssh
|
||||||
|
|
||||||
|
NAME=chargeman
|
||||||
|
DIR=/data
|
||||||
|
PIDFILE=/run/$NAME.pid
|
||||||
|
DAEMON=/data/chargeman/chargeman.py
|
||||||
|
DAEMON_ARGS=""
|
||||||
|
STOP_SIGNAL=INT
|
||||||
|
USER=root
|
||||||
|
LOG=/var/log/$NAME.log
|
||||||
|
|
||||||
|
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
|
||||||
|
|
||||||
|
common_opts="--quiet --chuid $USER --pidfile $PIDFILE"
|
||||||
|
|
||||||
|
do_start(){
|
||||||
|
start-stop-daemon --start $common_opts --chdir $DIR --make-pidfile --background --startas /bin/bash -- -c "exec /usr/bin/python $DAEMON $DAEMON_ARGS > $LOG 2>&1"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_stop(){
|
||||||
|
opt=${@:-}
|
||||||
|
start-stop-daemon --stop $common_opts --signal $STOP_SIGNAL --oknodo $opt --remove-pidfile
|
||||||
|
}
|
||||||
|
|
||||||
|
do_status(){
|
||||||
|
start-stop-daemon --status $common_opts && exit_status=$? || exit_status=$?
|
||||||
|
echo asdf $exit_status
|
||||||
|
case "$exit_status" in
|
||||||
|
0)
|
||||||
|
echo "Program '$NAME' is running."
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
echo "Program '$NAME' is not running and the pid file exists."
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "Program '$NAME' is not running."
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "Unable to determine program '$NAME' status."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
status)
|
||||||
|
do_status
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
echo -n "Starting daemon: "$NAME
|
||||||
|
do_start
|
||||||
|
echo "."
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
echo -n "Stopping daemon: "$NAME
|
||||||
|
do_stop
|
||||||
|
echo "."
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
echo -n "Restarting daemon: "$NAME
|
||||||
|
do_stop --retry 30
|
||||||
|
do_start
|
||||||
|
echo "."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: "$1" {status|start|stop|restart}"
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
from queue import SimpleQueue
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import configparser
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import tibberclient
|
||||||
|
import victronclient
|
||||||
|
import controller
|
||||||
|
import mqtthandler
|
||||||
|
|
||||||
|
queue = SimpleQueue()
|
||||||
|
|
||||||
|
queue_handler = logging.handlers.QueueHandler(queue)
|
||||||
|
listener = logging.handlers.QueueListener(
|
||||||
|
queue,
|
||||||
|
logging.StreamHandler(),
|
||||||
|
)
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
logger.addHandler(queue_handler)
|
||||||
|
formatter = logging.Formatter('[%(asctime)s] %(message)s')
|
||||||
|
queue_handler.setFormatter(formatter)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("Chargeman v0.2 starting")
|
||||||
|
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
|
||||||
|
config.read('config.ini')
|
||||||
|
|
||||||
|
|
||||||
|
if not 'tibber' in config:
|
||||||
|
raise Exception("Missing section tibber in config")
|
||||||
|
|
||||||
|
if not 'key' in config['tibber']:
|
||||||
|
raise Exception("Missing tibber key in config")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
victronconn = victronclient.client()
|
||||||
|
tibber = tibberclient.client(config['tibber']['key'])
|
||||||
|
cm = controller.chargeman_controller(victronconn,tibber)
|
||||||
|
mqtt = mqtthandler.handler(cm)
|
||||||
|
|
||||||
|
if 'battery_total' in config:
|
||||||
|
try:
|
||||||
|
cm.set_battery_total_net_delivery(float(config['battery_total']['net_delivery']))
|
||||||
|
except Exception:
|
||||||
|
logger.error("Unable to load net_delivery {}".format(config['battery_total']['net_delivery']))
|
||||||
|
exit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cm.set_battery_total_net_usage(float(config['battery_total']['net_usage']))
|
||||||
|
except Exception:
|
||||||
|
logger.error("Unable to load net_usage {}".format(config['battery_total']['net_usage']))
|
||||||
|
exit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if 'status' in config:
|
||||||
|
logger.info("Chargemode loaded: {}".format(int(config['status']['chargemode'])))
|
||||||
|
cm.set_chargemode(controller.Chargemode(int(config['status']['chargemode'])))
|
||||||
|
|
||||||
|
if 'chargevalue' in config['status']:
|
||||||
|
logger.info("Chargevalue loaded: {}".format(int(config['status']['chargevalue'])))
|
||||||
|
cm.set_chargevalue(int(config['status']['chargevalue']))
|
||||||
|
|
||||||
|
if 'valuekwh' in config['status']:
|
||||||
|
logger.info("valuekwh loaded: {}".format(float(config['status']['valuekwh'])))
|
||||||
|
cm.set_chargevaluekwh(float(config['status']['valuekwh']))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
lasthour = -1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def update():
|
||||||
|
logger.debug("Run update")
|
||||||
|
victronconn.update()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
cm.update()
|
||||||
|
|
||||||
|
config['battery_total'] = {}
|
||||||
|
config['battery_total']['net_usage'] = str(cm.get_battery_total_net_usage())
|
||||||
|
config['battery_total']['net_delivery'] = str(cm.get_battery_total_net_delivery())
|
||||||
|
|
||||||
|
config['status'] = {}
|
||||||
|
config['status']['chargemode'] = str(cm.get_chargemode_int())
|
||||||
|
config['status']['chargevalue'] = str(cm.get_chargevalue())
|
||||||
|
config['status']['valuekwh'] = str(cm.get_chargevaluekwh())
|
||||||
|
|
||||||
|
|
||||||
|
with open('config.ini', 'w') as configfile:
|
||||||
|
config.write(configfile)
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
update()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Update failed, retrying next cycle")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#pub=threading.Thread(target=publishing)
|
||||||
|
sub=threading.Thread(target=mqtt.subscribing)
|
||||||
|
main=threading.Thread(target=main)
|
||||||
|
|
||||||
|
|
||||||
|
listener.start()
|
||||||
|
sub.start()
|
||||||
|
tibber.start()
|
||||||
|
main.start()
|
||||||
|
|
||||||
|
atexit.register(listener.stop)
|
||||||
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[battery_total]
|
||||||
|
net_usage = 229505.52499994054
|
||||||
|
net_delivery = 135455.02500000477
|
||||||
|
|
||||||
|
[tibber]
|
||||||
|
key = nXJ69u_76l2cPpS3xn6F1HV-PKOmrT8-akCZE4Uru0c
|
||||||
|
|
||||||
|
[status]
|
||||||
|
chargemode = 8
|
||||||
|
chargevalue = 0
|
||||||
|
valuekwh = 0.0
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[battery_total]
|
||||||
|
net_usage = 229505.52499994054
|
||||||
|
net_delivery = 135455.02500000477
|
||||||
|
|
||||||
|
[tibber]
|
||||||
|
key = YOUR_TIBBER_API_TOKEN
|
||||||
|
|
||||||
|
[status]
|
||||||
|
chargemode = 8
|
||||||
|
chargevalue = 0
|
||||||
|
valuekwh = 0.0
|
||||||
|
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import functools
|
||||||
|
import victronclient
|
||||||
|
import mqttpublisher
|
||||||
|
import tibberclient
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
|
||||||
|
|
||||||
|
def _locked(method):
|
||||||
|
# Serialise access to controller state: MQTT callbacks and the main loop run in different threads
|
||||||
|
@functools.wraps(method)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
with self.lock:
|
||||||
|
return method(self, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
charge_max_price_level = 0.024
|
||||||
|
|
||||||
|
MAX_POWER=4000
|
||||||
|
MAX_CHARGE_POWER=4000
|
||||||
|
|
||||||
|
|
||||||
|
class Chargemode(IntEnum):
|
||||||
|
FLOATING=0
|
||||||
|
CHARGING_MANUAL=1
|
||||||
|
CHARGING_MAX=2
|
||||||
|
DISCHARGING_MANUAL=3
|
||||||
|
DISCHARGING_MAX=4
|
||||||
|
CHARGING_CHEAP=5
|
||||||
|
BALANCE_DYN=6
|
||||||
|
CHARGE_DYN=7
|
||||||
|
DISCHARGE_DYN=8
|
||||||
|
|
||||||
|
class chargeman_controller:
|
||||||
|
|
||||||
|
def __init__(self, victronconn: victronclient.client, tibber: tibberclient.client) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.last_update = None
|
||||||
|
self.chargemode=Chargemode.FLOATING
|
||||||
|
self.chargevalue=0
|
||||||
|
self.consump_updated=False
|
||||||
|
self.return_updated=False
|
||||||
|
self.consumption=0
|
||||||
|
self.return_delivery=0
|
||||||
|
self.netp1usage =0
|
||||||
|
self.valuekwh = 0.0
|
||||||
|
self.battery_total_net_usage = 0.0
|
||||||
|
self.battery_total_net_delivery = 0.0
|
||||||
|
self.setpoint = 0
|
||||||
|
self.netp1usageupdated = False
|
||||||
|
self.victronconn = victronconn
|
||||||
|
self.tibber = tibber
|
||||||
|
self.mqtt = mqttpublisher.publisher()
|
||||||
|
|
||||||
|
|
||||||
|
def set_battery_total_net_delivery(self, value):
|
||||||
|
self.battery_total_net_delivery = value
|
||||||
|
|
||||||
|
def get_battery_total_net_delivery(self):
|
||||||
|
return self.battery_total_net_delivery
|
||||||
|
|
||||||
|
def set_battery_total_net_usage(self, value):
|
||||||
|
self.battery_total_net_usage = value
|
||||||
|
|
||||||
|
def get_battery_total_net_usage(self):
|
||||||
|
return self.battery_total_net_usage
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def set_net_consumption(self, value):
|
||||||
|
self.consumption = value
|
||||||
|
self.consump_updated = True
|
||||||
|
self.check_net_usage()
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def set_net_delivery(self, value):
|
||||||
|
self.return_delivery = value
|
||||||
|
self.return_updated = True
|
||||||
|
self.check_net_usage()
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def check_net_usage(self):
|
||||||
|
|
||||||
|
|
||||||
|
if self.return_updated and self.consump_updated:
|
||||||
|
self.consump_updated=False
|
||||||
|
self.return_updated=False
|
||||||
|
|
||||||
|
if self.netp1usageupdated: # skip till next p1 cycle
|
||||||
|
self.netp1usageupdated=False
|
||||||
|
return
|
||||||
|
|
||||||
|
self.netp1usage = self.consumption - self.return_delivery
|
||||||
|
self.netp1usageupdated = True
|
||||||
|
|
||||||
|
|
||||||
|
#logger.info("Update P1 usage: {}".format(netp1usage))
|
||||||
|
if self.chargemode in [Chargemode.BALANCE_DYN,Chargemode.CHARGE_DYN,Chargemode.DISCHARGE_DYN]:
|
||||||
|
|
||||||
|
power_usage = self.victronconn.get_power_usage()
|
||||||
|
if power_usage is None:
|
||||||
|
logger.warning("Victron power usage unknown, skipping dynamic setpoint")
|
||||||
|
return
|
||||||
|
|
||||||
|
setpoint = -1 * power_usage
|
||||||
|
|
||||||
|
setpoint = self.netp1usage+setpoint
|
||||||
|
|
||||||
|
if self.chargemode==Chargemode.CHARGE_DYN and setpoint > 0:
|
||||||
|
setpoint =0
|
||||||
|
elif self.chargemode==Chargemode.DISCHARGE_DYN and setpoint < 0:
|
||||||
|
setpoint =0
|
||||||
|
|
||||||
|
|
||||||
|
self.setpoint = -1 * setpoint
|
||||||
|
|
||||||
|
if self.setpoint > MAX_CHARGE_POWER:
|
||||||
|
self.setpoint = MAX_CHARGE_POWER
|
||||||
|
elif self.setpoint < -MAX_POWER:
|
||||||
|
self.setpoint = -MAX_POWER
|
||||||
|
|
||||||
|
#logger.info ("Dynamic setpoint: {}".format(setpoint))
|
||||||
|
self.apply_charge_mode()
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def set_chargemode(self, mode: Chargemode):
|
||||||
|
self.chargemode = mode
|
||||||
|
|
||||||
|
self.apply_charge_mode()
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def set_chargemode_int(self, mode: int):
|
||||||
|
self.chargemode = Chargemode(mode)
|
||||||
|
self.apply_charge_mode()
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def set_chargevalue(self,value):
|
||||||
|
self.chargevalue = value
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_chargevalue(self):
|
||||||
|
return self.chargevalue
|
||||||
|
|
||||||
|
def get_chargemode(self) -> Chargemode:
|
||||||
|
return self.chargemode
|
||||||
|
|
||||||
|
def get_chargemode_int(self) -> int:
|
||||||
|
return int(self.chargemode)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_chargevaluekwh(self) -> float:
|
||||||
|
return self.valuekwh
|
||||||
|
|
||||||
|
|
||||||
|
def set_chargevaluekwh(self,value: float):
|
||||||
|
self.valuekwh = value
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def update(self):
|
||||||
|
|
||||||
|
logger.debug("Run controller update")
|
||||||
|
|
||||||
|
self.mqtt.loop()
|
||||||
|
|
||||||
|
logger.debug("Controller update mqtt loop done")
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = 0.0 if self.last_update is None else now - self.last_update
|
||||||
|
self.last_update = now
|
||||||
|
|
||||||
|
power_usage = self.victronconn.get_power_usage()
|
||||||
|
if power_usage is not None:
|
||||||
|
# W * s / 3600 = Wh, as kWh counter: elapsed real time instead of a fixed interval
|
||||||
|
if power_usage > 0:
|
||||||
|
self.battery_total_net_usage += power_usage / 60 / 60 * elapsed
|
||||||
|
elif power_usage < 0:
|
||||||
|
self.battery_total_net_delivery += (power_usage * -1) / 60 / 60 * elapsed
|
||||||
|
|
||||||
|
self.apply_charge_mode()
|
||||||
|
|
||||||
|
|
||||||
|
logger.debug("Controller update mqtt update batteries")
|
||||||
|
|
||||||
|
|
||||||
|
for battery in self.victronconn.batteries:
|
||||||
|
self.mqtt.publish_battery(battery)
|
||||||
|
|
||||||
|
|
||||||
|
logger.debug("Controller update mqtt update done")
|
||||||
|
|
||||||
|
|
||||||
|
logger.info('Mode={} CV={}W L1: SP={}W Net usage={}W Power={}W Battery: SoC={}% Current={:.2f}A TUsage={:.1f}KwH TDelivery{:.1f}KwH Tibber=€{}'.format(
|
||||||
|
str(self.chargemode),
|
||||||
|
int(self.chargevalue),
|
||||||
|
self.victronconn.get_setpoint(),
|
||||||
|
self.netp1usage,
|
||||||
|
self.victronconn.get_power_usage(),
|
||||||
|
self.victronconn.get_soc() ,
|
||||||
|
float(self.victronconn.get_battery_current() or 0),
|
||||||
|
self.battery_total_net_usage,
|
||||||
|
self.battery_total_net_delivery,
|
||||||
|
self.tibber.getPrice() ))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@_locked
|
||||||
|
def apply_charge_mode(self):
|
||||||
|
|
||||||
|
|
||||||
|
if self.chargemode in [Chargemode.CHARGING_MANUAL, Chargemode.DISCHARGING_MANUAL]:
|
||||||
|
self.setpoint = self.chargevalue
|
||||||
|
elif self.chargemode in [Chargemode.CHARGING_MAX]:
|
||||||
|
self.setpoint = MAX_CHARGE_POWER
|
||||||
|
elif self.chargemode in [Chargemode.DISCHARGING_MAX]:
|
||||||
|
self.setpoint = -MAX_POWER
|
||||||
|
|
||||||
|
elif self.chargemode == Chargemode.CHARGING_CHEAP:
|
||||||
|
if self.tibber.getPrice() == None:
|
||||||
|
logger.info("Tibber price unknown, doing nothing")
|
||||||
|
self.setpoint = 0
|
||||||
|
elif round(self.tibber.getPrice(),2) > charge_max_price_level:
|
||||||
|
logger.info("Energy prive {} too expensive, no charging".format(round(self.tibber.getPrice(),2)))
|
||||||
|
self.setpoint = 0
|
||||||
|
else:
|
||||||
|
logger.info("Energy prive {} ok, charging".format(round(self.tibber.getPrice(),2)))
|
||||||
|
self.setpoint = MAX_CHARGE_POWER
|
||||||
|
elif self.chargemode == Chargemode.FLOATING:
|
||||||
|
self.setpoint = 0
|
||||||
|
if self.setpoint > 0:
|
||||||
|
self.victronconn.set_charging()
|
||||||
|
elif self.setpoint == 0:
|
||||||
|
self.victronconn.set_idle()
|
||||||
|
else:
|
||||||
|
if self.setpoint < -MAX_POWER:
|
||||||
|
self.setpoint = -MAX_POWER
|
||||||
|
self.victronconn.set_discharging()
|
||||||
|
|
||||||
|
self.victronconn.set_setpoint(self.setpoint)
|
||||||
|
|
||||||
|
self.mqtt.publish_controller(self)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import controller
|
||||||
|
from mqtttopic import *
|
||||||
|
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class handler:
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, controller: controller.chargeman_controller):
|
||||||
|
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
|
||||||
|
self.publisher = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
|
||||||
|
|
||||||
|
self.client.on_connect = self.on_connect
|
||||||
|
self.client.on_message = self.on_message
|
||||||
|
self.controller = controller
|
||||||
|
|
||||||
|
|
||||||
|
self.variables = {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.connect(broker,port, 60)
|
||||||
|
self.client.loop_start()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Unable to connect to mqtt: {!r}".format(e))
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def subscribing(self):
|
||||||
|
pass
|
||||||
|
#while True:
|
||||||
|
# rc = self.client.loop(timeout=0.1)
|
||||||
|
# if rc != 0:
|
||||||
|
# if not self.client.is_connected:
|
||||||
|
# logger.warning("MQTT subscriber connection disconnected. Reconnecting...")
|
||||||
|
# self.client.reconnect()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# The callback for when the client receives a CONNACK response from the server.
|
||||||
|
def on_connect(self,client, userdata, flags, rc):
|
||||||
|
logger.info("Connected mqtt handler to mqtt with result code "+str(rc))
|
||||||
|
|
||||||
|
# Subscribing in on_connect() means that if we lose the connection and
|
||||||
|
# reconnect then subscriptions will be renewed.
|
||||||
|
client.subscribe(topic_chargemode)
|
||||||
|
client.subscribe(topic_p1_consumption)
|
||||||
|
client.subscribe(topic_p1_return)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# The callback for when a PUBLISH message is received from the server.
|
||||||
|
def on_message(self,client, userdata, msg):
|
||||||
|
|
||||||
|
logger.debug(msg.topic+" "+str(msg.payload))
|
||||||
|
|
||||||
|
|
||||||
|
if msg.topic == topic_chargemode:
|
||||||
|
self.controller.set_chargemode_int(int(msg.payload))
|
||||||
|
elif msg.topic == topic_p1_consumption:
|
||||||
|
|
||||||
|
self.controller.set_net_consumption( int(msg.payload))
|
||||||
|
|
||||||
|
|
||||||
|
elif msg.topic == topic_p1_return:
|
||||||
|
|
||||||
|
self.controller.set_net_delivery( int(msg.payload))
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from mqtttopic import *
|
||||||
|
import victronclient
|
||||||
|
import dbus
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
|
||||||
|
|
||||||
|
class publisher:
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
|
||||||
|
self.sensors = {}
|
||||||
|
self.mqtt_discovery_next_time = 0
|
||||||
|
|
||||||
|
self.client.on_connect = self.on_connect
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.connect(broker,port, 60)
|
||||||
|
self.client.loop_start()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Unable to connect to mqtt: {!r}".format(e))
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_to_python(self,data):
|
||||||
|
'''
|
||||||
|
convert dbus data types to python native data types
|
||||||
|
'''
|
||||||
|
if isinstance(data, dbus.String):
|
||||||
|
data = str(data)
|
||||||
|
elif isinstance(data, dbus.Boolean):
|
||||||
|
data = bool(data)
|
||||||
|
elif isinstance(data, dbus.Int64):
|
||||||
|
data = int(data)
|
||||||
|
elif isinstance(data, dbus.Int32):
|
||||||
|
data = int(data)
|
||||||
|
elif isinstance(data, dbus.Double):
|
||||||
|
data = float(data)
|
||||||
|
elif isinstance(data, dbus.Array):
|
||||||
|
data = [self.dbus_to_python(value) for value in data]
|
||||||
|
|
||||||
|
elif isinstance(data, dbus.Dictionary):
|
||||||
|
new_data = dict()
|
||||||
|
for key in data.keys():
|
||||||
|
new_data[self.dbus_to_python(key)] = self.dbus_to_python(data[key])
|
||||||
|
data = new_data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check_update_value(self, name, value):
|
||||||
|
value = self.dbus_to_python(value)
|
||||||
|
if not name in self.sensors:
|
||||||
|
self.sensors[name] = {'value': None, 'writable' : False}
|
||||||
|
|
||||||
|
if not (type(value) is int or type(value) is str or type(value) is float or value is None):
|
||||||
|
print ("Value {} for {} is not accepted (type {})".format(value, name,type(value)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not name in self.sensors or self.sensors[name]['value'] != value:
|
||||||
|
|
||||||
|
self.client.publish(topic_base + name + topic_state_postfix, value,retain=True)
|
||||||
|
self.sensors[name]['value'] = value
|
||||||
|
|
||||||
|
|
||||||
|
def publish_controller(self, controller):
|
||||||
|
|
||||||
|
self.check_update_value('chargemode', controller.get_chargemode_int())
|
||||||
|
self.check_update_value('chargevalue', controller.get_chargevalue())
|
||||||
|
self.check_update_value('actual_returndelivery', round(controller.get_battery_total_net_delivery()/ 1000,2))
|
||||||
|
self.check_update_value('actual_consumption', round(controller.get_battery_total_net_usage()/ 1000,2))
|
||||||
|
self.check_update_value('gridpower', controller.victronconn.get_power_usage())
|
||||||
|
self.check_update_value('soc', controller.victronconn.get_soc())
|
||||||
|
self.check_update_value('setpoint', controller.victronconn.get_setpoint())
|
||||||
|
self.check_update_value('battery_current', controller.victronconn.get_battery_current())
|
||||||
|
self.check_update_value('battery_power', controller.victronconn.get_battery_power())
|
||||||
|
self.check_update_value('battery_max_chargecurrent', controller.victronconn.get_battery_max_chargecurrent())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def publish_battery(self, battery: victronclient.battery):
|
||||||
|
|
||||||
|
self.check_update_value(battery.name + '_soc', battery.get_soc())
|
||||||
|
self.check_update_value(battery.name + '_power', battery.get_power())
|
||||||
|
self.check_update_value(battery.name + '_current', battery.get_current())
|
||||||
|
self.check_update_value(battery.name + '_temperature', battery.get_temperature())
|
||||||
|
self.check_update_value(battery.name + '_sum', battery.get_voltage_sum())
|
||||||
|
self.check_update_value(battery.name + '_low', battery.get_voltage_lowest())
|
||||||
|
self.check_update_value(battery.name + '_high', battery.get_voltage_highest())
|
||||||
|
self.check_update_value(battery.name + '_diff', battery.get_voltage_diff())
|
||||||
|
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
pass
|
||||||
|
#rc = self.client.loop(timeout=0.1)
|
||||||
|
#if rc != 0:
|
||||||
|
# if not self.client.is_connected:
|
||||||
|
# logger.warning("MQTT publisher connection disconnected. Reconnecting...")
|
||||||
|
# self.client.reconnect()
|
||||||
|
|
||||||
|
#if self.mqtt_discovery_next_time < time.time():
|
||||||
|
# self.mqtt_discovery_next_time = time.time() + 60
|
||||||
|
# self.announce()
|
||||||
|
|
||||||
|
# The callback for when the client receives a CONNACK response from the server.
|
||||||
|
def on_connect(self,client, userdata, flags, rc):
|
||||||
|
logger.info("Connected publisher to mqtt with result code "+str(rc))
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
#mqtt topics
|
||||||
|
topic_discovery_base = "homeassistant/"
|
||||||
|
topic_discovery = "/config"
|
||||||
|
topic_discovery_battery_component = "sensor"
|
||||||
|
topic_base = "avr95/battery/"
|
||||||
|
topic_command_postfix = "/set"
|
||||||
|
topic_state_postfix = "/state"
|
||||||
|
|
||||||
|
topic_p1_consumption = 'sensors/power/p1meter/actual_consumption'
|
||||||
|
topic_p1_return = 'sensors/power/p1meter/actual_returndelivery'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
topic_chargemode = topic_base + "chargemode" + topic_command_postfix
|
||||||
|
topic_power = topic_base + "chargevalue" + topic_command_postfix
|
||||||
|
|
||||||
|
topic_bat_consumption = topic_base + 'actual_consumption'
|
||||||
|
|
||||||
|
#battery packs
|
||||||
|
topic_batpack_soc = topic_base + '{}_soc' + topic_state_postfix
|
||||||
|
|
||||||
|
import os
|
||||||
|
broker=os.environ.get("CHARGEMAN_MQTT_BROKER", "10.2.0.15")
|
||||||
|
port=int(os.environ.get("CHARGEMAN_MQTT_PORT", "1883"))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
paho-mqtt<2
|
||||||
|
pyTibber
|
||||||
|
# dbus-python is provided by Venus OS
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import tibber.const
|
||||||
|
import tibber
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import datetime
|
||||||
|
import threading
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
|
||||||
|
TAXFEE=0.1088
|
||||||
|
TAX_RATE=1.21
|
||||||
|
SURCHARGE_PER_KWH=0.017520661
|
||||||
|
|
||||||
|
|
||||||
|
class client:
|
||||||
|
def __init__(self, key):
|
||||||
|
self.key = key
|
||||||
|
self.lasthour =-1
|
||||||
|
self.current_price = None
|
||||||
|
|
||||||
|
self.tibberupdate = threading.Thread(target=self.__updateTibber)
|
||||||
|
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.tibberupdate.start()
|
||||||
|
|
||||||
|
|
||||||
|
async def __getTibberData(self):
|
||||||
|
try:
|
||||||
|
tibber_connection = tibber.Tibber(self.key)
|
||||||
|
await tibber_connection.update_info()
|
||||||
|
logger.debug(tibber_connection.name)
|
||||||
|
|
||||||
|
home = tibber_connection.get_homes()[0]
|
||||||
|
await home.fetch_consumption_data()
|
||||||
|
await home.update_info()
|
||||||
|
logger.debug(home.address1)
|
||||||
|
|
||||||
|
await home.update_price_info()
|
||||||
|
logger.debug(home.current_price_info)
|
||||||
|
|
||||||
|
newrate=(home.current_price_info['energy'] + SURCHARGE_PER_KWH) * TAX_RATE
|
||||||
|
logger.info("Tibber price updated to {}".format(newrate))
|
||||||
|
await tibber_connection.close_connection()
|
||||||
|
return newrate
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
logger.error("Unable to refresh Tibber data: %s", repr(e))
|
||||||
|
|
||||||
|
|
||||||
|
def getPrice(self):
|
||||||
|
return self.current_price
|
||||||
|
|
||||||
|
def __updateTibber(self):
|
||||||
|
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
if now.hour != self.lasthour:
|
||||||
|
logger.info("Tibber price needs updating")
|
||||||
|
|
||||||
|
self.current_price = asyncio.run(self.__getTibberData())
|
||||||
|
self.lasthour= now.hour
|
||||||
|
time.sleep(1)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import dbus
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('main')
|
||||||
|
|
||||||
|
#dbus paths
|
||||||
|
path_setpoint= '/Hub4/L1/AcPowerSetpoint'
|
||||||
|
path_power= '/Ac/ActiveIn/L1/P'
|
||||||
|
|
||||||
|
path_bat_max_chargecurrent= '/BatteryOperationalLimits/MaxChargeCurrent'
|
||||||
|
path_bat_current= '/Dc/0/Current'
|
||||||
|
path_bat_power= '/Dc/0/Power'
|
||||||
|
path_disable_feedin ='/Hub4/DisableFeedIn'
|
||||||
|
path_disable_charge ='/Hub4/DisableCharge'
|
||||||
|
path_socvalue = '/Soc'
|
||||||
|
|
||||||
|
path_batt_socvalue = '/Soc'
|
||||||
|
path_batt_current = '/Dc/0/Current'
|
||||||
|
path_batt_power = '/Dc/0/Power'
|
||||||
|
path_batt_temperature = '/Dc/0/Temperature'
|
||||||
|
path_batt_voltage_sum = '/Dc/0/Voltage'
|
||||||
|
path_batt_voltage_lowest = '/System/MinCellVoltage'
|
||||||
|
path_batt_voltage_highest = '/System/MaxCellVoltage'
|
||||||
|
path_batt_voltage_diff = '/Voltages/Diff'
|
||||||
|
|
||||||
|
|
||||||
|
class client:
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.setpoint = 0
|
||||||
|
self.soc = None
|
||||||
|
self.power_usage = None
|
||||||
|
self.session_bus = dbus.SystemBus()
|
||||||
|
self.battery_power = None
|
||||||
|
self.battery_current = None
|
||||||
|
self.battery_max_chargecurrent = None
|
||||||
|
self.batteries = []
|
||||||
|
|
||||||
|
self.locate_device()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def __getPaths(self, object, current_path):
|
||||||
|
|
||||||
|
objectManager = self.session_bus.get_object(object, '/' if current_path == '' else current_path)
|
||||||
|
|
||||||
|
introspection_interface = dbus.Interface(
|
||||||
|
objectManager,
|
||||||
|
dbus.INTROSPECTABLE_IFACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
tree = ET.ElementTree(ET.fromstring(introspection_interface.Introspect()))
|
||||||
|
|
||||||
|
|
||||||
|
retval = []
|
||||||
|
root = tree.getroot()
|
||||||
|
for node in root:
|
||||||
|
if node.tag =='node':
|
||||||
|
print (current_path + '/' + node.attrib['name'])
|
||||||
|
for path in self.__getPaths(object,current_path + '/' + node.attrib['name'] ):
|
||||||
|
retval.append(current_path + node.attrib['name'] + '/' +path )
|
||||||
|
|
||||||
|
return retval
|
||||||
|
|
||||||
|
|
||||||
|
def listPaths(self, object):
|
||||||
|
print(self.__getPaths(object,''))
|
||||||
|
#return self.__getPaths(object,'/')
|
||||||
|
|
||||||
|
|
||||||
|
def locate_device(self):
|
||||||
|
logger.info("Locating victron devices")
|
||||||
|
for name in self.session_bus.list_names():
|
||||||
|
if name[0:17] == "com.victronenergy":
|
||||||
|
parts = name.split(".")
|
||||||
|
if parts[2]=='vebus':
|
||||||
|
print("Detected vbus {}".format(name))
|
||||||
|
self.device = name
|
||||||
|
if parts[2]=='battery':
|
||||||
|
batteryname = self.session_bus.call_blocking(name, "/Serial",None,"GetValue","",[])
|
||||||
|
print("Detected battery {} ({})".format(name,batteryname))
|
||||||
|
self.batteries.append(battery(self,name,batteryname))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def set_charging(self):
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_feedin ,None,'SetValue', 'v', [1])
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_charge ,None,'SetValue', 'v', [0])
|
||||||
|
|
||||||
|
|
||||||
|
def set_idle(self):
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_feedin ,None,'SetValue', 'v', [1])
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_charge ,None,'SetValue', 'v', [1])
|
||||||
|
|
||||||
|
|
||||||
|
def set_discharging(self):
|
||||||
|
|
||||||
|
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_feedin ,None,'SetValue', 'v', [0])
|
||||||
|
self.session_bus.call_blocking(self.device, path_disable_charge ,None,'SetValue', 'v', [0])
|
||||||
|
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info("Connecting to dbus...")
|
||||||
|
logger.info("Return from setpoint: {}".format(self.session_bus.call_blocking(self.device, path_setpoint ,None,'SetValue', 'v', [self.setpoint])))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Unable to connect to dbus {}: {!r}".format(path_setpoint, e))
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def get_power_usage(self):
|
||||||
|
return self.power_usage
|
||||||
|
|
||||||
|
def get_soc(self):
|
||||||
|
return self.soc
|
||||||
|
|
||||||
|
def get_setpoint(self):
|
||||||
|
return self.setpoint
|
||||||
|
|
||||||
|
def set_setpoint(self, setpoint):
|
||||||
|
try:
|
||||||
|
self.session_bus.call_blocking(self.device, path_setpoint ,None,'SetValue', 'v', [setpoint])
|
||||||
|
self.setpoint = setpoint
|
||||||
|
except Exception as e:
|
||||||
|
# keep the last known setpoint; None would break later comparisons
|
||||||
|
logger.error("Unable to set dbus {}: {!r}".format(path_setpoint, e))
|
||||||
|
|
||||||
|
|
||||||
|
def get_battery_current(self):
|
||||||
|
return self.battery_current
|
||||||
|
|
||||||
|
def get_battery_power(self):
|
||||||
|
return self.battery_power
|
||||||
|
|
||||||
|
|
||||||
|
def get_battery_max_chargecurrent(self):
|
||||||
|
return self.battery_max_chargecurrent
|
||||||
|
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
|
||||||
|
|
||||||
|
logger.debug("Run victron update")
|
||||||
|
self.power_usage = self.session_bus.call_blocking(self.device, path_power ,None,'GetValue', '', [])
|
||||||
|
|
||||||
|
if not isinstance(self.power_usage, int):
|
||||||
|
logger.info('Power usage is not an int value')
|
||||||
|
self.power_usage = None
|
||||||
|
return
|
||||||
|
|
||||||
|
self.soc = self.session_bus.call_blocking(self.device, path_socvalue ,None,'GetValue', '', [])
|
||||||
|
if not isinstance(self.soc, float):
|
||||||
|
logger.info('SoC is not a float value')
|
||||||
|
self.soc = None
|
||||||
|
return
|
||||||
|
|
||||||
|
self.setpoint = self.session_bus.call_blocking(self.device, path_setpoint ,None,'GetValue', '', [])
|
||||||
|
self.battery_power = self.session_bus.call_blocking(self.device, path_bat_power ,None,'GetValue', '', [])
|
||||||
|
self.battery_current = self.session_bus.call_blocking(self.device, path_bat_current ,None,'GetValue', '', [])
|
||||||
|
self.battery_max_chargecurrent = self.session_bus.call_blocking(self.device, path_bat_max_chargecurrent ,None,'GetValue', '', [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for battery in self.batteries:
|
||||||
|
battery.update()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class battery:
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, victron: client, device: str, name: str) -> None:
|
||||||
|
self.soc = None
|
||||||
|
self.current = None
|
||||||
|
self.power = None
|
||||||
|
self.temperature = None
|
||||||
|
self.voltage_sum = None
|
||||||
|
self.voltage_lowest = None
|
||||||
|
self.voltage_highest = None
|
||||||
|
self.voltage_diff = None
|
||||||
|
self.name = name
|
||||||
|
self.device = device
|
||||||
|
self.victron = victron
|
||||||
|
|
||||||
|
#self.paths = victron.listPaths(device)
|
||||||
|
|
||||||
|
def get_name(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
def get_soc(self):
|
||||||
|
return self.soc
|
||||||
|
|
||||||
|
def get_current(self):
|
||||||
|
return self.current
|
||||||
|
|
||||||
|
def get_power(self):
|
||||||
|
return self.power
|
||||||
|
|
||||||
|
def get_temperature(self):
|
||||||
|
return self.temperature
|
||||||
|
|
||||||
|
def get_voltage_sum(self):
|
||||||
|
return self.voltage_sum
|
||||||
|
|
||||||
|
def get_voltage_lowest(self):
|
||||||
|
return self.voltage_lowest
|
||||||
|
|
||||||
|
def get_voltage_highest(self):
|
||||||
|
return self.voltage_highest
|
||||||
|
|
||||||
|
def get_voltage_diff(self):
|
||||||
|
return self.voltage_diff
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
self.soc = self.victron.session_bus.call_blocking(self.device,path_batt_socvalue,None,'GetValue', '', [])
|
||||||
|
self.current = self.victron.session_bus.call_blocking(self.device,path_batt_current,None,'GetValue', '', [])
|
||||||
|
self.power = self.victron.session_bus.call_blocking(self.device,path_batt_power,None,'GetValue', '', [])
|
||||||
|
self.temperature = self.victron.session_bus.call_blocking(self.device,path_batt_temperature,None,'GetValue', '', [])
|
||||||
|
self.voltage_sum = self.victron.session_bus.call_blocking(self.device,path_batt_voltage_sum,None,'GetValue', '', [])
|
||||||
|
self.voltage_lowest = self.victron.session_bus.call_blocking(self.device,path_batt_voltage_lowest,None,'GetValue', '', [])
|
||||||
|
self.voltage_highest = self.victron.session_bus.call_blocking(self.device,path_batt_voltage_highest,None,'GetValue', '', [])
|
||||||
|
self.voltage_diff = self.victron.session_bus.call_blocking(self.device,path_batt_voltage_diff,None,'GetValue', '', [])
|
||||||
|
|
||||||
Reference in New Issue
Block a user