From 1c7eef7089f55d527bf7576925454a756b7515bc Mon Sep 17 00:00:00 2001 From: Carsten Schmiemann Date: Sat, 20 May 2023 17:44:18 +0200 Subject: [PATCH] Added batrium native protocol implementation, not working atm --- dbus_batrium_native/batrium.py | 158 ++++++++++++ dbus_batrium_native/dbus_batrium_native.py | 274 +++++++++++++++++++++ dbus_batrium_native/requirements.sh | 3 + dbus_batrium_native/service/run | 3 + 4 files changed, 438 insertions(+) create mode 100644 dbus_batrium_native/batrium.py create mode 100644 dbus_batrium_native/dbus_batrium_native.py create mode 100644 dbus_batrium_native/requirements.sh create mode 100644 dbus_batrium_native/service/run diff --git a/dbus_batrium_native/batrium.py b/dbus_batrium_native/batrium.py new file mode 100644 index 0000000..06bf75f --- /dev/null +++ b/dbus_batrium_native/batrium.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +import logging +import itertools + +from time import time +from datetime import datetime +import can +import struct +from argparse import ArgumentParser + +VERSION = '1.0' + +class BatriumBattery(can.Listener): + def __init__(self, capacity, connection): + self.capacity = capacity + self.maxChargeVoltage = 0 + self.numberOfModules = 3 + self.chargeComplete = 0 + self.soc = 0 + self.voltage = 0 + self.current = 0 + self.temperature = 0 + self.maxCellTemperature = 0 + self.minCellTemperature = 0 + self.cellVoltages_min =[(0,0,0,0) for i in range(self.numberOfModules)] + self.cellVoltages_max =[(0,0,0,0) for i in range(self.numberOfModules)] + self.cellTemperatures =[(0,0,0,0) for i in range(self.numberOfModules)] + self.cellBypassTemperatures =[(0,0,0,0) for i in range(self.numberOfModules)] + self.cellBypassPWM =[(0,0,0,0) for i in range(self.numberOfModules)] + self.maxCellVoltage = 0 + self.maxCellVoltageId = 0 + self.minCellVoltage = 0 + self.minCellVoltageId = 0 + self.maxChargeCurrent = 0 + self.maxDischargeCurrent = 0 + self.firmwareVersion = '2.17.39' + self.updated = -1 + self.cyclicModeTask = None + self.TimeToEmpty = 0 + self.TimeToFull = 0 + + self._ci = can.interface.Bus(channel=connection, bustype='socketcan') + + # check connection and that reported system voltage roughly matches configuration + found = False + msg = None + + while True: + try: + msg = self._ci.recv(timeout=10) + except can.CanError: + logging.error("Canbus error") + + if msg == None: + #timeout no system connected + logging.error("No messages on canbus %s received. Check connection and speed." % connection) + break; + + elif msg.arbitration_id == 0x00111500: + logging.info("Found Batrium BMS on %s" % connection) + self.soc = (msg.data[0] * 0.5) - 5 + self.voltage = ((msg.data[3] * 256) + msg.data[2]) * 0.01 + self.current = (struct.unpack('>f', bytearray([msg.data[7], msg.data[6], msg.data[5], msg.data[4]])))[0] * 0.001 + self.temperature = msg.data[1] - 40 + + logging.debug("SOC: %2d, I: %2.2fA, U: %2.2fV, T:%2.1fC" % (self.soc, self.current, self.voltage, self.temperature)) + + found = True + break; + + def on_message_received(self, msg): + self.updated = msg.timestamp + logging.debug("CAN message arrived") + if msg.arbitration_id == 0x00111500: + self.soc = (msg.data[0] * 0.5) - 5 + self.voltage = ((msg.data[3] * 256) + msg.data[2]) * 0.01 + self.current = round((struct.unpack('>f', bytearray([msg.data[7], msg.data[6], msg.data[5], msg.data[4]])))[0] * 0.001, 2) + self.temperature = msg.data[1] - 40 + + elif msg.arbitration_id == 0x00111800: + self.TimeToEmpty = ((msg.data[2] * 256) + msg.data[1]) + + elif msg.arbitration_id == 0x00111700: + self.TimeToFull = ((msg.data[2] * 256) + msg.data[1]) + + elif msg.arbitration_id == 0x00140400: + self.maxDischargeCurrent = ((msg.data[3] * 256) + msg.data[2]) * 0.1 + logging.debug("Idmax %dA", self.maxDischargeCurrent) + logging.debug("I: %dA U: %dV",self.current, self.voltage) + + elif msg.arbitration_id == 0x00140300: + self.maxChargeVoltage = ((msg.data[7] * 256) + msg.data[6]) * 0.01 + self.maxChargeCurrent = ((msg.data[3] * 256) + msg.data[2]) * 0.01 + logging.debug("Icmax %dA, Ivmax %dA", self.maxChargeCurrent, self.maxChargeVoltage) + logging.debug("I: %dA U: %dV",self.current, self.voltage) + + elif msg.arbitration_id == 0x00161700: + self.minCellTemperature = msg.data[0] - 40 + self.maxCellTemperature = msg.data[1] - 40 + logging.debug("Min Cell Temp: %d, Max Cell Temp: %d", self.minCellTemperature, self.maxCellTemperature) + + elif msg.arbitration_id == 0x00161100: + self.minCellVoltage = ((msg.data[1] * 256) + msg.data[0]) * 0.001 + self.maxCellVoltage = ((msg.data[3] * 256) + msg.data[2]) * 0.001 + self.minCellVoltageId = msg.data[4] + self.maxCellVoltageId = msg.data[5] + logging.debug("Umin %1.3fV Umax %1.3fV", self.minCellVoltage, self.maxCellVoltage) + + elif ((msg.arbitration_id >= 0x001D1101) and (msg.arbitration_id <= 0x001D11F9)): + cell_id = msg.arbitration_id & 0xff + logging.debug("Cell ID: %d", cell_id) + #self.cellVoltages_min[cell_id] = ((msg.data[1] * 256) + msg.data[0]) * 0.001 + #self.cellVoltages_max[cell_id] = ((msg.data[3] * 256) + msg.data[2]) * 0.001 + #self.cellTemperatures[cell_id] = msg.data[4] - 40 + #self.cellBypassTemperatures[cell_id] = msg.data[5] - 40 + #self.cellBypassPWM[cell_id] = msg.data[6] + + def prnt(self): + print("SOC: %2d, I: %2.2fA, U: %2.2fV, T:%2.1fC" % (self.soc, self.current, self.voltage, self.maxCellTemperature)) + print("Umin: %1.2f, Umax: %1.2f Udelta: %1.2f" % (self.minCellVoltage, self.maxCellVoltage, self.maxCellVoltage-self.minCellVoltage)) + print("CCL: %4.1f A CVL: %2.2f V" % (self.maxChargeCurrent, self.maxChargeVoltage)) + +# === All code below is to simply run it from the commandline for debugging purposes === +def main(): + + logging.info('Starting dbus_batrium_native listener') + + #logger = can.Logger('logfile.asc') + + bat = BatriumBattery(capacity=300, connection='can0') + + listeners = [ + #logger, # Regular Listener object + bat + ] + + notifier = can.Notifier(bat._ci, listeners) + for msg in bat._ci: + if msg.arbitration_id == 0x00140100: + bat.prnt() + + # Clean-up + notifier.stop() + bat._ci.shutdown() + + + logging.basicConfig( format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=(logging.DEBUG), + handlers=[ + logging.FileHandler("%s/current.log" % (os.path.dirname(os.path.realpath(__file__)))), + logging.StreamHandler() + ]) + + +if __name__ == "__main__": + main() diff --git a/dbus_batrium_native/dbus_batrium_native.py b/dbus_batrium_native/dbus_batrium_native.py new file mode 100644 index 0000000..1788d17 --- /dev/null +++ b/dbus_batrium_native/dbus_batrium_native.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 + +""" +A class to put a battery service on the dbus, according to victron standards, with constantly updating +paths. + +""" +from gi.repository import GLib +import platform +import argparse +import logging +import sys +import os +import dbus +import itertools +import math + +from time import time +from datetime import datetime +import struct +from argparse import ArgumentParser + +from batrium import * + + +# our own packages +sys.path.insert(1, os.path.join(os.path.dirname(__file__), '/opt/victronenergy/dbus-systemcalc-py/ext/velib_python')) +from vedbus import VeDbusService +from ve_utils import exit_on_error +from settingsdevice import SettingsDevice + +VERSION = '1.0' + +def handle_changed_setting(setting, oldvalue, newvalue): + logging.debug('setting changed, setting: %s, old: %s, new: %s' % (setting, oldvalue, newvalue)) + + +class DbusBatteryService: + def __init__(self, servicename, deviceinstance, capacity, productname='Batrium BMS', connection='can0'): + self.minUpdateDone = 0 + self.dailyResetDone = 0 + self.lastUpdated = 0 + self._bat = BatriumBattery(capacity=capacity, connection=connection) + + try: + self._dbusservice = VeDbusService(servicename+'.socketcan_'+connection+'_di'+str(deviceinstance)) + except: + exit + + # Create the management objects, as specified in the ccgx dbus-api document + self._dbusservice.add_path('/Mgmt/ProcessName', __file__) + self._dbusservice.add_path('/Mgmt/ProcessVersion', VERSION + ' running on Python ' + platform.python_version()) + self._dbusservice.add_path('/Mgmt/Connection', connection) + + # Create the mandatory objects + self._dbusservice.add_path('/DeviceInstance', deviceinstance) + self._dbusservice.add_path('/ProductId', 0) + self._dbusservice.add_path('/ProductName', productname) + self._dbusservice.add_path('/FirmwareVersion', 'unknown') + self._dbusservice.add_path('/HardwareVersion', 'unknown') + self._dbusservice.add_path('/Connected', 0) + # Create battery specific objects + self._dbusservice.add_path('/Status', 0) + self._dbusservice.add_path('/Soh', 100) + self._dbusservice.add_path('/Capacity', int(capacity)) + self._dbusservice.add_path('/InstalledCapacity', int(capacity)) + self._dbusservice.add_path('/Dc/0/Temperature', 20) + self._dbusservice.add_path('/Info/MaxChargeCurrent', 5) + self._dbusservice.add_path('/Info/MaxDischargeCurrent', 5) + self._dbusservice.add_path('/Info/MaxChargeVoltage', 0) + self._dbusservice.add_path('/Info/BatteryLowVoltage', 48.0) + self._dbusservice.add_path('/Alarms/CellImbalance', 0) + self._dbusservice.add_path('/Alarms/LowVoltage', 0) + self._dbusservice.add_path('/Alarms/HighVoltage', 0) + self._dbusservice.add_path('/Alarms/HighDischargeCurrent', 0) + self._dbusservice.add_path('/Alarms/HighChargeCurrent', 0) + self._dbusservice.add_path('/Alarms/LowSoc', 0) + self._dbusservice.add_path('/Alarms/LowTemperature', 0) + self._dbusservice.add_path('/Alarms/HighTemperature', 0) + self._dbusservice.add_path('/Balancing', 0) + self._dbusservice.add_path('/System/HasTemperature', 1) + self._dbusservice.add_path('/System/NrOfBatteries', 1) + self._dbusservice.add_path('/System/NrOfModulesOnline', 1) + self._dbusservice.add_path('/System/NrOfModulesOffline', 0) + self._dbusservice.add_path('/System/NrOfModulesBlockingDischarge', 0) + self._dbusservice.add_path('/System/NrOfModulesBlockingCharge', 0) + self._dbusservice.add_path('/System/NrOfBatteriesBalancing', 0) + self._dbusservice.add_path('/System/BatteriesParallel', 1) + self._dbusservice.add_path('/System/BatteriesSeries', 1) + self._dbusservice.add_path('/System/NrOfCellsPerBattery', 4) + self._dbusservice.add_path('/System/MinVoltageCellId', 0) + self._dbusservice.add_path('/System/MaxVoltageCellId', 0) + self._dbusservice.add_path('/System/MinCellTemperature', 10.0) + self._dbusservice.add_path('/System/MaxCellTemperature', 10.0) + + self._settings = SettingsDevice( + bus=dbus.SystemBus() if (platform.machine() == 'armv7l') else dbus.SessionBus(), + supportedSettings={ + 'AvgDischarge': ['/Settings/Batrium/AvgerageDischarge', 0.0,0,0], + 'TotalAhDrawn': ['/Settings/Batrium/TotalAhDrawn', 0.0,0,0], + 'TimeLastFull': ['/Settings/Batrium/TimeLastFull', 0.0 ,0,0], + 'MinCellVoltage': ['/Settings/Batrium/MinCellVoltage', 4.0,2.0,4.2], + 'MaxCellVoltage': ['/Settings/Batrium/MaxCellVoltage', 2.0,2.0,4.2], + 'interval': ['/Settings/Batrium/Interval', 10, 10, 10] + }, + eventCallback=handle_changed_setting) + + + self._summeditems = { + '/System/MaxCellVoltage': {'gettext': '%.2F V'}, + '/System/MinCellVoltage': {'gettext': '%.2F V'}, + '/Dc/0/Voltage': {'gettext': '%.2F V'}, + '/Dc/0/Current': {'gettext': '%.1F A'}, + '/Dc/0/Power': {'gettext': '%.0F W'}, + '/Soc': {'gettext': '%.0F %%'}, + '/History/TotalAhDrawn': {'gettext': '%.0F Ah'}, + '/History/DischargedEnergy': {'gettext': '%.2F kWh'}, + '/History/ChargedEnergy': {'gettext': '%.2F kWh'}, + '/History/AverageDischarge': {'gettext': '%.2F kWh'}, + '/TimeToGo': {'gettext': '%.0F s'}, + '/ConsumedAmphours': {'gettext': '%.1F Ah'} + } + + for path in self._summeditems.keys(): + self._dbusservice.add_path(path, value=None, gettextcallback=self._gettext) + + self._dbusservice['/History/AverageDischarge'] = self._settings['AvgDischarge'] + self._dbusservice['/History/TotalAhDrawn'] = self._settings['TotalAhDrawn'] + self._dbusservice.add_path('/History/TimeSinceLastFullCharge', 0) + self._dbusservice.add_path('/History/MinCellVoltage', self._settings['MinCellVoltage']) + self._dbusservice.add_path('/History/MaxCellVoltage', self._settings['MaxCellVoltage']) + self._dbusservice['/ConsumedAmphours'] = 0 + + logging.info("History cell voltage min: %.3f, max: %.3f, totalAhDrawn: %d", + self._settings['MinCellVoltage'], self._settings['MaxCellVoltage'], self._settings['TotalAhDrawn']) + + self._dbusservice['/History/ChargedEnergy'] = 0 + self._dbusservice['/History/DischargedEnergy'] = 0 + + GLib.timeout_add( self._settings['interval'], exit_on_error, self._update) + + def _gettext(self, path, value): + item = self._summeditems.get(path) + if item is not None: + return item['gettext'] % value + return str(value) + + def __del__(self): + self._safe_history() + logging.info('Stopping dbus_Batrium') + + + def _safe_history(self): + logging.debug('Saving history to localsettings') + self._settings['AvgDischarge'] = self._dbusservice['/History/AverageDischarge'] + self._settings['TotalAhDrawn'] = self._dbusservice['/History/TotalAhDrawn'] + self._settings['MinCellVoltage'] = self._dbusservice['/History/MinCellVoltage'] + self._settings['MaxCellVoltage'] = self._dbusservice['/History/MaxCellVoltage'] + + + def _daily_stats(self): + if (self._dbusservice['/History/DischargedEnergy'] == 0): return + logging.info("Updating stats, SOC: %d, Discharged: %.2f, Charged: %.2f ",self._bat.soc, self._dbusservice['/History/DischargedEnergy'], self._dbusservice['/History/ChargedEnergy']) + self._dbusservice['/History/AverageDischarge'] = (6*self._dbusservice['/History/AverageDischarge'] + self._dbusservice['/History/DischargedEnergy'])/7 #rolling week + self._dbusservice['/History/ChargedEnergy'] = 0 + self._dbusservice['/History/DischargedEnergy'] = 0 + dt = datetime.now() - datetime.fromtimestamp( float(self._settings['TimeLastFull']) ) + + #if full within the last 24h and more than *0% consumed, estimate actual capacity and SOH based on consumed amphours from full and SOC reported + if dt.total_seconds() < 24*3600 and self._bat.soc < 70: + self._dbusservice['/Capacity'] = int(-self._dbusservice['/ConsumedAmphours'] * 100 / (100-self._bat.soc)) + self._dbusservice['/Soh'] = int(self._dbusservice['/Capacity']*100 / self._dbusservice['/InstalledCapacity']) + logging.info("SOH: %d, Capacity: %d ", self._dbusservice['/Soh'], self._dbusservice['/Capacity']) + self.dailyResetDone = datetime.now().day + + + def _update(self): + if (self._bat.updated != -1 and self.lastUpdated == 0) or ((self._bat.updated - self.lastUpdated) < 1000): + self.lastUpdated = self._bat.updated + self._dbusservice['/Connected'] = 1 + else: + self._dbusservice['/Connected'] = 0 + + self._dbusservice['/Dc/0/Current'] = self._bat.current + self._dbusservice['/Dc/0/Voltage'] = self._bat.voltage + power = self._bat.voltage * self._bat.current + self._dbusservice['/Dc/0/Power'] = power + self._dbusservice['/Dc/0/Temperature'] = self._bat.maxCellTemperature + + self._dbusservice['/System/MaxVoltageCellId'] = self._bat.maxCellVoltageId + self._dbusservice['/System/MinVoltageCellId'] = self._bat.minCellVoltageId + self._dbusservice['/System/MaxCellVoltage'] = self._bat.maxCellVoltage + if (self._bat.maxCellVoltage > self._dbusservice['/History/MaxCellVoltage'] ): + self._dbusservice['/History/MaxCellVoltage'] = self._bat.maxCellVoltage + logging.info("New maximum cell voltage: %f", self._bat.maxCellVoltage) + self._dbusservice['/System/MinCellVoltage'] = self._bat.minCellVoltage + if (0 < self._bat.minCellVoltage < self._dbusservice['/History/MinCellVoltage'] ): + self._dbusservice['/History/MinCellVoltage'] = self._bat.minCellVoltage + logging.info("New minimum cell voltage: %f", self._bat.minCellVoltage) + self._dbusservice['/System/MinCellTemperature'] = self._bat.minCellTemperature + self._dbusservice['/System/MaxCellTemperature'] = self._bat.maxCellTemperature + self._dbusservice['/Info/MaxChargeCurrent'] = self._bat.maxChargeCurrent + self._dbusservice['/Info/MaxDischargeCurrent'] = self._bat.maxDischargeCurrent + self._dbusservice['/Info/MaxChargeVoltage'] = self._bat.maxChargeVoltage + self._dbusservice['/System/NrOfModulesOnline'] = self._bat.numberOfModules + + #update energy statistics daily at 6:00, + if datetime.now().hour == 6 and datetime.now().minute == 0 and datetime.now().day != self.dailyResetDone : + self._daily_stats() + + now = datetime.now().time() + if now.minute != self.minUpdateDone: + self.minUpdateDone = now.minute + if self._bat.current > 0: + #charging + self._dbusservice['/TimeToGo'] = self._bat.TimeToFull + else : + #discharging + self._dbusservice['/TimeToGo'] = self._bat.TimeToEmpty + + self._safe_history() + + return True + +def main(): + parser = ArgumentParser(description='dbus_batrium', add_help=True) + parser.add_argument('-d', '--debug', help='enable debug logging', + action='store_true') + parser.add_argument('-i', '--interface', help='CAN interface') + parser.add_argument('-c', '--capacity', help='capacity in Ah') + parser.add_argument('-p', '--print', help='print only') + + args = parser.parse_args() + + logging.basicConfig( format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=(logging.DEBUG if args.debug else logging.INFO), + handlers=[ + logging.FileHandler("%s/current.log" % (os.path.dirname(os.path.realpath(__file__)))), + logging.StreamHandler() + ]) + + if not args.interface: + logging.info('No CAN interface specified, using default can0') + args.interface = 'can0' + + if not args.capacity: + logging.warning('Battery capacity not specified, using default (100Ah)') + args.capacity = 100 + + logging.info('Starting dbus_batrium %s on %s ' % + (VERSION, args.interface)) + + logging.debug('Setup main loop') + + from dbus.mainloop.glib import DBusGMainLoop + # Have a mainloop, so we can send/receive asynchronous calls to and from dbus + DBusGMainLoop(set_as_default=True) + + logging.debug('Setup dbus service') + + battery_output = DbusBatteryService( + servicename='com.victronenergy.battery', + connection = args.interface, + deviceinstance = 0, + capacity = int(args.capacity), + ) + + logging.debug('Connected to dbus, and switching over to GLib.MainLoop() (= event based)') + mainloop = GLib.MainLoop() + mainloop.run() + +if __name__ == "__main__": + main() diff --git a/dbus_batrium_native/requirements.sh b/dbus_batrium_native/requirements.sh new file mode 100644 index 0000000..2486b34 --- /dev/null +++ b/dbus_batrium_native/requirements.sh @@ -0,0 +1,3 @@ +opkg update +opkg install python3-pip +pip3 install python-can diff --git a/dbus_batrium_native/service/run b/dbus_batrium_native/service/run new file mode 100644 index 0000000..f65a430 --- /dev/null +++ b/dbus_batrium_native/service/run @@ -0,0 +1,3 @@ +#!/bin/sh +exec 2>&1 +exec softlimit -d 100000000 -s 1000000 -a 100000000 python /data/dbus_batrium_native/dbus_batrium_native.py -i can0 -c 300