├── service ├── log │ └── run └── run ├── installservice.sh ├── rpi_bt_firmware ├── BCM4345C0_003.001.025.0171.0339.hcd ├── BCM4345C0_003.001.025.0190.0382_RPI_3P.hcd ├── README.md └── installfirmware.sh ├── scan.py ├── qml ├── PageBatteryParameters.qml ├── PageLynxIonIo.qml ├── PageBatterySettings.qml ├── install-qml.sh ├── PageBatterySetup.qml ├── PageBatteryCellVoltages.qml └── PageBattery.qml ├── clearpass.py ├── README.md ├── dbus-btbattery.py ├── virtual.py ├── default_config.ini ├── jkbt.py ├── utils.py ├── jbdbt.py ├── dbushelper.py └── battery.py /service/log/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec 2>&1 3 | exec multilog t s25000 n4 /var/log/dbus-btbattery 4 | -------------------------------------------------------------------------------- /service/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec 2>&1 3 | exec /opt/victronenergy/dbus-btbattery/dbus-btbattery.py 70:3e:97:08:00:62 4 | -------------------------------------------------------------------------------- /installservice.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/sh 3 | mkdir /opt/victronenergy/service/dbus-btbattery/ 4 | cp -a service/* /opt/victronenergy/service/dbus-btbattery/ 5 | -------------------------------------------------------------------------------- /rpi_bt_firmware/BCM4345C0_003.001.025.0171.0339.hcd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradcagle/dbus-btbattery/HEAD/rpi_bt_firmware/BCM4345C0_003.001.025.0171.0339.hcd -------------------------------------------------------------------------------- /rpi_bt_firmware/BCM4345C0_003.001.025.0190.0382_RPI_3P.hcd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradcagle/dbus-btbattery/HEAD/rpi_bt_firmware/BCM4345C0_003.001.025.0190.0382_RPI_3P.hcd -------------------------------------------------------------------------------- /rpi_bt_firmware/README.md: -------------------------------------------------------------------------------- 1 | # Raspberry Pi Bluetooth Firmware 2 | 3 | ### Bt firmware update for CYW43455 (RPi 3B+, 4B, CM4) 4 | 5 | This firmware might fix lockups, and/or connection issues 6 | 7 | Install with ./installfirmware.sh 8 | -------------------------------------------------------------------------------- /rpi_bt_firmware/installfirmware.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/sh 3 | #mv /lib/firmware/brcm/BCM4345C0.hcd{,.bak} 4 | #cp BCM4345C0_003.001.025.0171.0339.hcd /lib/firmware/brcm/BCM4345C0.hcd 5 | cp BCM4345C0_003.001.025.0190.0382_RPI_3P.hcd /lib/firmware/brcm/BCM4345C0.hcd 6 | -------------------------------------------------------------------------------- /scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bluepy.btle import Scanner, DefaultDelegate 3 | 4 | class ScanDelegate(DefaultDelegate): 5 | def __init__(self): 6 | DefaultDelegate.__init__(self) 7 | 8 | def handleDiscovery(self, dev, isNewDev, isNewData): 9 | if isNewDev: 10 | print( "Discovered device ", dev.addr) 11 | elif isNewData: 12 | print( "Received new data from ", dev.addr) 13 | 14 | scanner = Scanner().withDelegate(ScanDelegate()) 15 | devices = scanner.scan(10.0) 16 | 17 | for dev in devices: 18 | print( "Device %s (%s), RSSI=%d dB" % (dev.addr, dev.addrType, dev.rssi) ) 19 | for (adtype, desc, value) in dev.getScanData(): 20 | print( " %s = %s" % (desc, value) ) 21 | -------------------------------------------------------------------------------- /qml/PageBatteryParameters.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import com.victron.velib 1.0 3 | 4 | MbPage { 5 | id: root 6 | 7 | property variant service 8 | 9 | model: VisibleItemModel { 10 | 11 | MbItemValue { 12 | description: qsTr("Charge Mode") 13 | item.bind: service.path("/Info/ChargeMode") 14 | show: item.valid 15 | } 16 | 17 | MbItemValue { 18 | description: qsTr("Charge Voltage Limit (CVL)") 19 | item.bind: service.path("/Info/MaxChargeVoltage") 20 | } 21 | 22 | MbItemValue { 23 | description: qsTr("Charge Limitation") 24 | item.bind: service.path("/Info/ChargeLimitation") 25 | show: item.valid 26 | } 27 | 28 | MbItemValue { 29 | description: qsTr("Charge Current Limit (CCL)") 30 | item.bind: service.path("/Info/MaxChargeCurrent") 31 | } 32 | 33 | MbItemValue { 34 | description: qsTr("Discharge Limitation") 35 | item.bind: service.path("/Info/DischargeLimitation") 36 | show: item.valid 37 | } 38 | 39 | MbItemValue { 40 | description: qsTr("Discharge Current Limit (DCL)") 41 | item.bind: service.path("/Info/MaxDischargeCurrent") 42 | } 43 | 44 | MbItemValue { 45 | description: qsTr("Low Voltage Disconnect (always ignored)") 46 | item.bind: service.path("/Info/BatteryLowVoltage") 47 | showAccessLevel: User.AccessService 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /clearpass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bluepy.btle import Peripheral, DefaultDelegate, BTLEException 3 | import struct 4 | import argparse 5 | import sys 6 | import time 7 | import binascii 8 | import atexit 9 | 10 | 11 | 12 | class delegate(DefaultDelegate): 13 | 14 | def __init__(self): 15 | DefaultDelegate.__init__(self) 16 | 17 | def handleNotification(self, cHandle, data): 18 | hex_data = binascii.hexlify(data) 19 | hex_string = hex_data.decode('utf-8') 20 | print(hex_string) 21 | 22 | 23 | def main(): 24 | 25 | 26 | if len(sys.argv) > 1: 27 | address = str(sys.argv[1]) 28 | else: 29 | print("Need address arg") 30 | exit() 31 | 32 | 33 | try: 34 | print('Connecting to BMS ' + address) 35 | bt = Peripheral(address, addrType="public") 36 | except BTLEException as ex: 37 | time.sleep(10) 38 | bt = Peripheral(address, addrType="public") 39 | except BTLEException as ex: 40 | print('Connection failed') 41 | exit() 42 | else: 43 | print('Connected ', address) 44 | 45 | 46 | d = delegate() 47 | bt.setDelegate(d) 48 | 49 | # [Start Byte][write][clearpass reg][payload len][payload ][checksum][stop byte] 50 | # dd 5a 09 06 4a 31 42 32 44 34 fe8a 77 51 | # J 1 B 2 D 4 52 | # 53 | # Checksum = 65536 - ([reg byte] + [payload len byte] + [payload bytes]) 54 | 55 | bt.writeCharacteristic(0x15, b'\xdd\x5a\x09\x06\x4a\x31\x42\x32\x44\x34\xfe\x8a\x77', True) 56 | 57 | bt.waitForNotifications(10.0) 58 | 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /qml/PageLynxIonIo.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import "utils.js" as Utils 3 | 4 | MbPage { 5 | id: root 6 | property string bindPrefix 7 | 8 | model: VisibleItemModel { 9 | MbItemOptions { 10 | id: systemSwitch 11 | description: qsTr("System Switch") 12 | bind: Utils.path(bindPrefix, "/SystemSwitch") 13 | readonly: true 14 | show: item.valid 15 | possibleValues:[ 16 | MbOption{description: qsTr("Disabled"); value: 0}, 17 | MbOption{description: qsTr("Enabled"); value: 1} 18 | ] 19 | } 20 | 21 | MbItemOptions { 22 | description: qsTr("Allow to charge") 23 | bind: Utils.path(bindPrefix, "/Io/AllowToCharge") 24 | readonly: true 25 | possibleValues:[ 26 | MbOption{description: qsTr("No"); value: 0}, 27 | MbOption{description: qsTr("Yes"); value: 1} 28 | ] 29 | } 30 | 31 | MbItemOptions { 32 | description: qsTr("Allow to discharge") 33 | bind: Utils.path(bindPrefix, "/Io/AllowToDischarge") 34 | readonly: true 35 | possibleValues:[ 36 | MbOption{description: qsTr("No"); value: 0}, 37 | MbOption{description: qsTr("Yes"); value: 1} 38 | ] 39 | } 40 | 41 | MbItemOptions { 42 | description: qsTr("Allow to balance") 43 | bind: service.path("/Io/AllowToBalance") 44 | readonly: true 45 | show: item.valid 46 | possibleValues:[ 47 | MbOption{description: qsTr("No"); value: 0}, 48 | MbOption{description: qsTr("Yes"); value: 1} 49 | ] 50 | } 51 | 52 | MbItemOptions { 53 | description: qsTr("External relay") 54 | bind: Utils.path(bindPrefix, "/Io/ExternalRelay") 55 | readonly: true 56 | show: item.valid 57 | possibleValues:[ 58 | MbOption{description: qsTr("Inactive"); value: 0}, 59 | MbOption{description: qsTr("Active"); value: 1} 60 | ] 61 | } 62 | 63 | MbItemOptions { 64 | description: qsTr("Programmable Contact") 65 | bind: Utils.path(bindPrefix, "/Io/ProgrammableContact") 66 | readonly: true 67 | show: item.valid 68 | possibleValues:[ 69 | MbOption{description: qsTr("Inactive"); value: 0}, 70 | MbOption{description: qsTr("Active"); value: 1} 71 | ] 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dbus-btbattery 2 | This is a driver for VenusOS devices (As of yet only tested on Raspberry Pi running the VenusOS v2.92 image). 3 | 4 | The driver will communicate with a Battery Management System (BMS) via Bluetooth and publish this data to the VenusOS system. 5 | 6 | This project is derived from Louis Van Der Walt's dbus-serialbattery found here: https://github.com/Louisvdw/dbus-serialbattery 7 | 8 | ### Instructions 9 | To get started you need a VenusOS device. I've only tried on Raspberry Pi, you can follow my instructions here: https://www.youtube.com/watch?v=yvGdNOZQ0Rw 10 | to set one up. 11 | 12 | 13 | You need to setup some depenacies on your VenusOS first 14 | 15 | 1) SSH to IP assigned to venus device
16 | 17 | 2) Resize/Expand file system
18 | /opt/victronenergy/swupdate-scripts/resize2fs.sh 19 | 20 | 3) Update opkg
21 | opkg update 22 | 23 | 4) Install pip
24 | opkg install python3-pip 25 | 26 | 5) Install build essentials as bluepy has some C code that needs to be compiled
27 | opkg install packagegroup-core-buildessential 28 | 29 | 6) Install glib-dev required by bluepy
30 | opkg install libglib-2.0-dev 31 | 32 | 7) Install bluepi
33 | pip3 install bluepy 34 | 35 | 8) Install git
36 | opkg install git 37 | 38 | 9) Clone dbus-btbattery repo
39 | cd /opt/victronenergy/
40 | git clone https://github.com/bradcagle/dbus-btbattery.git 41 | 42 | cd dbus-btbattery
43 | You can now run ./dbus-btbattery.py 70:3e:97:08:00:62
44 | replace 70:3e:97:08:00:62 with the Bluetooth address of your BMS/Battery
45 | 46 | You can run ./scan.py to find Bluetooth devices around you
47 | 48 | ### To make dbus-btbattery startup automatically 49 | nano service/run
50 | and replace 70:3e:97:08:00:62 with the Bluetooth address of your BMS/Battery
51 | Save with "Ctrl O"
52 | run ./installservice.sh
53 | reboot
54 | 55 | 56 | ### New Virtual Battery Feature [Experimental] 57 | You can now add up to 4 bt battery addresses to the command line. It will connect to all batteries, and create a
58 | single virtual battery. NOTE for now this only works with batteries in series, I will add parallel support soon.
59 | 60 | Example of my two 12v batteries in series, the display shows a 24v battery
61 | ./dbus-btbattery.py 70:3e:97:08:00:62 a4:c1:37:40:89:5e
62 | 63 | 64 | NOTES: This driver is far from complete, so some things will probably be broken. Also only JBD BMS is currenly supported 65 | 66 | 67 | -------------------------------------------------------------------------------- /dbus-btbattery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | from typing import Union 4 | 5 | from time import sleep 6 | from dbus.mainloop.glib import DBusGMainLoop 7 | from threading import Thread 8 | import sys 9 | 10 | if sys.version_info.major == 2: 11 | import gobject 12 | else: 13 | from gi.repository import GLib as gobject 14 | 15 | # Victron packages 16 | # from ve_utils import exit_on_error 17 | 18 | from dbushelper import DbusHelper 19 | from utils import logger 20 | import utils 21 | from battery import Battery 22 | from jbdbt import JbdBt 23 | from virtual import Virtual 24 | 25 | 26 | 27 | logger.info("Starting dbus-btbattery") 28 | 29 | 30 | def main(): 31 | def poll_battery(loop): 32 | # Run in separate thread. Pass in the mainloop so the thread can kill us if there is an exception. 33 | poller = Thread(target=lambda: helper.publish_battery(loop)) 34 | # Thread will die with us if deamon 35 | poller.daemon = True 36 | poller.start() 37 | return True 38 | 39 | 40 | def get_btaddr() -> str: 41 | # Get the bluetooth address we need to use from the argument 42 | if len(sys.argv) > 1: 43 | return sys.argv[1:] 44 | else: 45 | return False 46 | 47 | 48 | logger.info( 49 | "dbus-btbattery v" + str(utils.DRIVER_VERSION) + utils.DRIVER_SUBVERSION 50 | ) 51 | 52 | btaddr = get_btaddr() 53 | if len(btaddr) == 2: 54 | battery: Battery = Virtual( JbdBt(btaddr[0]), JbdBt(btaddr[1]) ) 55 | elif len(btaddr) == 3: 56 | battery: Battery = Virtual( JbdBt(btaddr[0]), JbdBt(btaddr[1]), JbdBt(btaddr[2]) ) 57 | elif len(btaddr) == 4: 58 | battery: Battery = Virtual( JbdBt(btaddr[0]), JbdBt(btaddr[1]), JbdBt(btaddr[2]), JbdBt(btaddr[3]) ) 59 | else: 60 | battery: Battery = JbdBt(btaddr[0]) 61 | 62 | if battery is None: 63 | logger.error("ERROR >>> No battery connection at " + str(btaddr)) 64 | sys.exit(1) 65 | 66 | battery.log_settings() 67 | 68 | # Have a mainloop, so we can send/receive asynchronous calls to and from dbus 69 | DBusGMainLoop(set_as_default=True) 70 | if sys.version_info.major == 2: 71 | gobject.threads_init() 72 | mainloop = gobject.MainLoop() 73 | 74 | # Get the initial values for the battery used by setup_vedbus 75 | helper = DbusHelper(battery) 76 | 77 | if not helper.setup_vedbus(): 78 | logger.error("ERROR >>> Problem with battery " + str(btaddr)) 79 | sys.exit(1) 80 | 81 | # Poll the battery at INTERVAL and run the main loop 82 | gobject.timeout_add(battery.poll_interval, lambda: poll_battery(mainloop)) 83 | try: 84 | mainloop.run() 85 | except KeyboardInterrupt: 86 | pass 87 | 88 | 89 | if __name__ == "__main__": 90 | main() 91 | -------------------------------------------------------------------------------- /qml/PageBatterySettings.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import com.victron.velib 1.0 3 | import "utils.js" as Utils 4 | 5 | MbPage { 6 | id: root 7 | property string bindPrefix 8 | 9 | model: VisibleItemModel { 10 | MbSubMenu { 11 | id: battery 12 | description: qsTr("Battery bank") 13 | subpage: Component { 14 | PageBatterySettingsBattery { 15 | title: battery.description 16 | bindPrefix: root.bindPrefix 17 | } 18 | } 19 | } 20 | 21 | MbSubMenu { 22 | id: alarms 23 | description: qsTr("Alarms") 24 | subpage: Component { 25 | PageBatterySettingsAlarm { 26 | title: alarms.description 27 | bindPrefix: root.bindPrefix 28 | } 29 | } 30 | } 31 | 32 | MbSubMenu { 33 | id: relay 34 | description: qsTr("Relay (on battery monitor)") 35 | subpage: Component { 36 | PageBatterySettingsRelay { 37 | title: relay.description 38 | bindPrefix: root.bindPrefix 39 | } 40 | } 41 | } 42 | 43 | MbItemOptions { 44 | description: qsTr("Restore factory defaults") 45 | bind: Utils.path(root.bindPrefix, "/Settings/RestoreDefaults") 46 | text: qsTr("Press to restore") 47 | show: valid 48 | possibleValues: [ 49 | MbOption { description: qsTr("Cancel"); value: 0 }, 50 | MbOption { description: qsTr("Restore"); value: 1 } 51 | ] 52 | } 53 | 54 | MbItemNoYes { 55 | description: qsTr("Bluetooth Enabled") 56 | bind: Utils.path(bindPrefix, "/Settings/BluetoothMode") 57 | show: valid 58 | } 59 | 60 | 61 | MbSpinBox { 62 | description: "Reset SoC to" 63 | item.bind: Utils.path(bindPrefix, "/Settings/ResetSoc") 64 | item.min: 0 65 | item.max: 100 66 | item.step: 1 67 | } 68 | 69 | 70 | 71 | 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /qml/install-qml.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # remove comment for easier troubleshooting 4 | #set -x 5 | 6 | # elaborate version string for better comparing 7 | # https://github.com/kwindrem/SetupHelper/blob/ebaa65fcf23e2bea6797f99c1c41174143c1153c/updateFileSets#L56-L81 8 | function versionStringToNumber () 9 | { 10 | local local p4="" ; local p5="" ; local p5="" 11 | local major=""; local minor="" 12 | 13 | # first character should be 'v' so first awk parameter will be empty and is not prited into the read command 14 | # 15 | # version number formats: v2.40, v2.40~6, v2.40-large-7, v2.40~6-large-7 16 | # so we must adjust how we use paramters read from the version string 17 | # and parsed by awk 18 | # if no beta make sure release is greater than any beta (i.e., a beta portion of 999) 19 | 20 | read major minor p4 p5 p6 <<< $(echo $1 | awk -v FS='[v.~-]' '{print $2, $3, $4, $5, $6}') 21 | ((versionNumber = major * 1000000000 + minor * 1000000)) 22 | if [ -z $p4 ] || [ $p4 = "large" ]; then 23 | ((versionNumber += 999)) 24 | else 25 | ((versionNumber += p4)) 26 | fi 27 | if [ ! -z $p4 ] && [ $p4 = "large" ]; then 28 | ((versionNumber += p5 * 1000)) 29 | large=$p5 30 | elif [ ! -z $p6 ]; then 31 | ((versionNumber += p6 * 1000)) 32 | fi 33 | } 34 | 35 | # backup old PageBattery.qml once. New firmware upgrade will remove the backup 36 | if [ ! -f /opt/victronenergy/gui/qml/PageBattery.qml.backup ]; then 37 | cp /opt/victronenergy/gui/qml/PageBattery.qml /opt/victronenergy/gui/qml/PageBattery.qml.backup 38 | fi 39 | # backup old PageBatteryParameters.qml once. New firmware upgrade will remove the backup 40 | if [ ! -f /opt/victronenergy/gui/qml/PageBatteryParameters.qml.backup ]; then 41 | cp /opt/victronenergy/gui/qml/PageBatteryParameters.qml /opt/victronenergy/gui/qml/PageBatteryParameters.qml.backup 42 | fi 43 | # backup old PageBatterySettings.qml once. New firmware upgrade will remove the backup 44 | if [ ! -f /opt/victronenergy/gui/qml/PageBatterySettings.qml.backup ]; then 45 | cp /opt/victronenergy/gui/qml/PageBatterySettings.qml /opt/victronenergy/gui/qml/PageBatterySettings.qml.backup 46 | fi 47 | # backup old PageLynxIonIo.qml once. New firmware upgrade will remove the backup 48 | if [ ! -f /opt/victronenergy/gui/qml/PageLynxIonIo.qml.backup ]; then 49 | cp /opt/victronenergy/gui/qml/PageLynxIonIo.qml /opt/victronenergy/gui/qml/PageLynxIonIo.qml.backup 50 | fi 51 | # copy new PageBattery.qml 52 | cp PageBattery.qml /opt/victronenergy/gui/qml/ 53 | # copy new PageBatteryCellVoltages 54 | cp PageBatteryCellVoltages.qml /opt/victronenergy/gui/qml/ 55 | # copy new PageBatteryParameters.qml 56 | cp PageBatteryParameters.qml /opt/victronenergy/gui/qml/ 57 | # copy new PageBatterySettings.qml 58 | cp PageBatterySettings.qml /opt/victronenergy/gui/qml/ 59 | # copy new PageBatterySetup 60 | cp PageBatterySetup.qml /opt/victronenergy/gui/qml/ 61 | # copy new PageLynxIonIo.qml 62 | cp PageLynxIonIo.qml /opt/victronenergy/gui/qml/ 63 | 64 | 65 | # get current Venus OS version 66 | versionStringToNumber $(head -n 1 /opt/victronenergy/version) 67 | ((venusVersionNumber = $versionNumber)) 68 | 69 | # revert to VisualItemModel, if Venus OS older than v3.00~14 (v3.00~14 uses VisibleItemModel) 70 | versionStringToNumber "v3.00~14" 71 | 72 | # change in Victron directory, else the files are "broken" if upgrading from v2 to v3 73 | qmlDir="/opt/victronenergy/gui/qml" 74 | 75 | if (( $venusVersionNumber < $versionNumber )); then 76 | echo -n "Venus OS $(head -n 1 /opt/victronenergy/version) is older than v3.00~14. Replacing VisibleItemModel with VisualItemModel... " 77 | fileList="$qmlDir/PageBattery.qml" 78 | fileList+=" $qmlDir/PageBatteryCellVoltages.qml" 79 | fileList+=" $qmlDir/PageBatteryParameters.qml" 80 | fileList+=" $qmlDir/PageBatterySettings.qml" 81 | fileList+=" $qmlDir/PageBatterySetup.qml" 82 | fileList+=" $qmlDir/PageLynxIonIo.qml" 83 | for file in $fileList ; do 84 | sed -i -e 's/VisibleItemModel/VisualItemModel/' "$file" 85 | done 86 | echo "done." 87 | fi 88 | 89 | 90 | # stop gui 91 | svc -d /service/gui 92 | # sleep 1 sec 93 | sleep 1 94 | # start gui 95 | svc -u /service/gui 96 | -------------------------------------------------------------------------------- /virtual.py: -------------------------------------------------------------------------------- 1 | from battery import Protection, Battery, Cell 2 | from utils import * 3 | from struct import * 4 | import argparse 5 | import sys 6 | import time 7 | import binascii 8 | import atexit 9 | 10 | 11 | 12 | class Virtual(Battery): 13 | def __init__(self, b1=None, b2=None, b3=None, b4=None): 14 | Battery.__init__(self, 0, 0, 0) 15 | 16 | self.type = "Virtual" 17 | self.port = "/" + self.type 18 | 19 | self.batts = [] 20 | if b1: 21 | self.batts.append(b1) 22 | if b2: 23 | self.batts.append(b2) 24 | if b3: 25 | self.batts.append(b3) 26 | if b4: 27 | self.batts.append(b4) 28 | 29 | 30 | def test_connection(self): 31 | return False 32 | 33 | 34 | def get_settings(self): 35 | self.voltage = 0 36 | self.current = 0 37 | self.cycles = 0 38 | self.production = 0 39 | self.soc = 0 40 | self.cell_count = 0 41 | self.capacity = 0 42 | self.capacity_remain = 0 43 | self.charge_fet = True 44 | self.discharge_fet = True 45 | 46 | result = False 47 | # Loop through all batteries 48 | for b in self.batts: 49 | result = b.get_settings(); 50 | if result: 51 | # Add battery voltages together 52 | self.voltage += b.voltage 53 | 54 | # Add cell counts 55 | self.cell_count += b.cell_count 56 | 57 | # Add current values, and div by cell count after the loop to get avg 58 | self.current += b.current 59 | 60 | # Use the highest cycle count 61 | if b.cycles > self.cycles: 62 | self.cycles = b.cycles 63 | 64 | # Use the lowest capacity value 65 | if b.capacity < self.capacity or self.capacity == 0: 66 | self.capacity = b.capacity 67 | 68 | # Use the lowest capacity_remain value 69 | if b.capacity_remain < self.capacity_remain or self.capacity_remain == 0: 70 | self.capacity_remain = b.capacity_remain 71 | 72 | # Use the lowest SOC value 73 | if b.soc < self.soc or self.soc == 0: 74 | self.soc = b.soc 75 | 76 | self.charge_fet &= b.charge_fet 77 | self.discharge_fet &= b.discharge_fet 78 | 79 | 80 | self.cells = [None]*self.cell_count 81 | 82 | bcnt = len(self.batts) 83 | 84 | if bcnt: 85 | # Avg the current 86 | self.current /= bcnt 87 | 88 | # Use the temp sensors from the first battery? 89 | self.temp_sensors = self.batts[0].temp_sensors 90 | self.temp1 = self.batts[0].temp1 91 | self.temp2 = self.batts[0].temp2 92 | 93 | 94 | self.max_battery_voltage = MAX_CELL_VOLTAGE * self.cell_count 95 | self.min_battery_voltage = MIN_CELL_VOLTAGE * self.cell_count 96 | 97 | self.max_battery_charge_current = MAX_BATTERY_CHARGE_CURRENT 98 | self.max_battery_discharge_current = MAX_BATTERY_DISCHARGE_CURRENT 99 | return result 100 | 101 | 102 | def refresh_data(self): 103 | result = self.get_settings() 104 | 105 | # Clear cells list 106 | self.cells: List[Cell] = [] 107 | 108 | result2 = False 109 | # Loop through all batteries 110 | for b in self.batts: 111 | result2 = b.refresh_data(); 112 | if result2: 113 | # Append cells list 114 | self.cells += b.cells 115 | 116 | 117 | result = result and result2 118 | return result 119 | 120 | 121 | def log_settings(self): 122 | # Override log_settings() to call get_settings() first 123 | self.get_settings() 124 | Battery.log_settings(self) 125 | 126 | 127 | 128 | 129 | 130 | # Unit test 131 | if __name__ == "__main__": 132 | from jbdbt import JbdBt 133 | 134 | batt1 = JbdBt( "70:3e:97:08:00:62" ) 135 | batt2 = JbdBt( "a4:c1:37:40:89:5e" ) 136 | 137 | vbatt = Virtual(batt1, batt2) 138 | 139 | vbatt.get_settings() 140 | 141 | print("Cells " + str(vbatt.cell_count) ) 142 | print("Voltage " + str(vbatt.voltage) ) 143 | 144 | while True: 145 | vbatt.refresh_data() 146 | 147 | print("Cells " + str(vbatt.cell_count) ) 148 | print("Voltage " + str(vbatt.voltage) ) 149 | print("Current " + str(vbatt.current) ) 150 | print("Charge FET " + str(vbatt.charge_fet) ) 151 | print("Discharge FET " + str(vbatt.discharge_fet) ) 152 | 153 | for c in range(vbatt.cell_count): 154 | print( str(vbatt.cells[c].voltage) + "v", end=" " ) 155 | 156 | print("") 157 | 158 | 159 | time.sleep(5) 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /default_config.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | LINEAR_LIMITATION_ENABLE = False 3 | 4 | ; battery Current limits 5 | MAX_BATTERY_CHARGE_CURRENT = 70.0 6 | MAX_BATTERY_DISCHARGE_CURRENT = 90.0 7 | 8 | ; -------- Cell Voltage limitation --------- 9 | ; Description: 10 | ; Maximal charge / discharge current will be in-/decreased depending on min- and max-cell-voltages 11 | ; Example: 18cells * 3.55V/cell = 63.9V max charge voltage. 18 * 2.7V = 48,6V min discharge voltage 12 | ; ... but the (dis)charge current will be (in-/)decreased, if even ONE SINGLE BATTERY CELL reaches the limits 13 | 14 | ; Charge current control management referring to cell-voltage enable (True/False). 15 | CCCM_CV_ENABLE = True 16 | ; Discharge current control management referring to cell-voltage enable (True/False). 17 | DCCM_CV_ENABLE = True 18 | 19 | ; Set Steps to reduce battery current. The current will be changed linear between those steps 20 | CELL_VOLTAGES_WHILE_CHARGING = 3.55,3.50,3.45,3.30 21 | MAX_CHARGE_CURRENT_CV_FRACTION = 0,0.05,0.5,1 22 | 23 | CELL_VOLTAGES_WHILE_DISCHARGING = 2.70,2.80,2.90,3.10 24 | MAX_DISCHARGE_CURRENT_CV_FRACTION = 0,0.1,0.5,1 25 | 26 | ; -------- Temperature limitation --------- 27 | ; Description: 28 | ; Maximal charge / discharge current will be in-/decreased depending on temperature 29 | ; Example: The temperature limit will be monitored to control the currents. If there are two temperature senors, 30 | ; then the worst case will be calculated and the more secure lower current will be set. 31 | ; Charge current control management referring to temperature enable (True/False). 32 | CCCM_T_ENABLE = True 33 | ; Charge current control management referring to temperature enable (True/False). 34 | DCCM_T_ENABLE = True 35 | 36 | ; Set Steps to reduce battery current. The current will be changed linear between those steps 37 | TEMPERATURE_LIMITS_WHILE_CHARGING = 0,2,5,10,15,20,35,40,55 38 | MAX_CHARGE_CURRENT_T_FRACTION = 0,0.1,0.2,0.4,0.8,1,1,0.4,0 39 | 40 | TEMPERATURE_LIMITS_WHILE_DISCHARGING = -20,0,5,10,15,45,55 41 | MAX_DISCHARGE_CURRENT_T_FRACTION = 0,.2,.3,.4,1,1,0 42 | 43 | ; if the cell voltage reaches 3.55V, then reduce current battery-voltage by 0.01V 44 | ; if the cell voltage goes over 3.6V, then the maximum penalty will not be exceeded 45 | ; there will be a sum of all penalties for each cell, which exceeds the limits 46 | PENALTY_AT_CELL_VOLTAGE = 3.45,3.55,3.6 47 | ; this voltage will be subtracted 48 | PENALTY_BATTERY_VOLTAGE = 0.01,1.0,2.0 49 | 50 | 51 | ; -------- SOC limitation --------- 52 | ; Description: 53 | ; Maximal charge / discharge current will be increased / decreased depending on State of Charge, see CC_SOC_LIMIT1 etc. 54 | ; The State of Charge (SoC) charge / discharge current will be in-/decreased depending on SOC. 55 | ; Example: 16cells * 3.45V/cell = 55,2V max charge voltage. 16*2.9V = 46,4V min discharge voltage 56 | ; Cell min/max voltages - used with the cell count to get the min/max battery voltage 57 | MIN_CELL_VOLTAGE = 2.9 58 | MAX_CELL_VOLTAGE = 3.45 59 | FLOAT_CELL_VOLTAGE = 3.35 60 | MAX_VOLTAGE_TIME_SEC = 900 61 | SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = 90 62 | 63 | ; Charge current control management enable (True/False). 64 | CCCM_SOC_ENABLE = True 65 | ; Discharge current control management enable (True/False). 66 | DCCM_SOC_ENABLE = True 67 | 68 | ; charge current soc limits 69 | CC_SOC_LIMIT1 = 98 70 | CC_SOC_LIMIT2 = 95 71 | CC_SOC_LIMIT3 = 91 72 | 73 | ; charge current limits 74 | CC_CURRENT_LIMIT1_FRACTION = 0.1 75 | CC_CURRENT_LIMIT2_FRACTION = 0.3 76 | CC_CURRENT_LIMIT3_FRACTION = 0.5 77 | 78 | ; discharge current soc limits 79 | DC_SOC_LIMIT1 = 10 80 | DC_SOC_LIMIT2 = 20 81 | DC_SOC_LIMIT3 = 30 82 | 83 | ; discharge current limits 84 | DC_CURRENT_LIMIT1_FRACTION = 0.1 85 | DC_CURRENT_LIMIT2_FRACTION = 0.3 86 | DC_CURRENT_LIMIT3_FRACTION = 0.5 87 | 88 | ; Charge voltage control management enable (True/False). 89 | CVCM_ENABLE = False 90 | 91 | ; Simulate Midpoint graph (True/False). 92 | MIDPOINT_ENABLE = False 93 | 94 | ; soc low levels 95 | SOC_LOW_WARNING = 20 96 | SOC_LOW_ALARM = 10 97 | 98 | ; Daly settings 99 | ; Battery capacity (amps) if the BMS does not support reading it 100 | BATTERY_CAPACITY = 50 101 | ; Invert Battery Current. Default non-inverted. Set to -1 to invert 102 | INVERT_CURRENT_MEASUREMENT = 1 103 | 104 | ; TIME TO SOC settings [Valid values 0-100, but I don't recommend more that 20 intervals] 105 | ; Set of SoC percentages to report on dbus. The more you specify the more it will impact system performance. 106 | ; TIME_TO_SOC_POINTS = [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] 107 | ; Every 5% SoC 108 | ; TIME_TO_SOC_POINTS = [100, 95, 90, 85, 75, 50, 25, 20, 10, 0] 109 | ; No data set to disable 110 | TIME_TO_SOC_POINTS = 111 | ; Specify TimeToSoc value type: [Valid values 1,2,3] 112 | ; TIME_TO_SOC_VALUE_TYPE = 1 ; Seconds 113 | ; TIME_TO_SOC_VALUE_TYPE = 2 ; Time string HH:MN:SC 114 | ; Both Seconds and time str " [days, HR:MN:SC]" 115 | TIME_TO_SOC_VALUE_TYPE = 3 116 | ; Specify how many loop cycles between each TimeToSoc updates 117 | TIME_TO_SOC_LOOP_CYCLES = 5 118 | ; Include TimeToSoC points when moving away from the SoC point. [Valid values True,False] 119 | ; These will be as negative time. Disabling this improves performance slightly. 120 | TIME_TO_SOC_INC_FROM = False 121 | 122 | 123 | ; Select the format of cell data presented on dbus. [Valid values 0,1,2,3] 124 | ; 0 Do not publish all the cells (only the min/max cell data as used by the default GX) 125 | ; 1 Format: /Voltages/Cell; (also available for display on Remote Console) 126 | ; 2 Format: /Cell/#/Volts 127 | ; 3 Both formats 1 and 2 128 | BATTERY_CELL_DATA_FORMAT = 1 129 | 130 | ; Settings for ESC GreenMeter and Lipro devices 131 | GREENMETER_ADDRESS = 1 132 | LIPRO_START_ADDRESS = 2 133 | LIPRO_END_ADDRESS = 4 134 | LIPRO_CELL_COUNT = 15 135 | 136 | PUBLISH_CONFIG_VALUES = 1 137 | 138 | BMS_TYPE = 139 | -------------------------------------------------------------------------------- /qml/PageBatterySetup.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import com.victron.velib 1.0 3 | import "utils.js" as Utils 4 | 5 | MbPage { 6 | id: root 7 | property string bindPrefix: "com.victronenergy.settings//Settings/Devices/serialbattery/" 8 | 9 | property VBusItem cellVoltageMin: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CellVoltageMin")} 10 | property VBusItem cellVoltageMax: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CellVoltageMax")} 11 | property VBusItem cellVoltageFloat: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CellVoltageFloat")} 12 | property VBusItem voltageMaxTime: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/VoltageMaxTime")} 13 | property VBusItem voltageResetSocLimit: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/VoltageResetSocLimit")} 14 | property VBusItem maxCurrentCharge: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/MaxCurrentCharge")} 15 | property VBusItem maxCurrentDischarge: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/MaxCurrentDischarge")} 16 | property VBusItem allowDynamicChargeCurrent: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/AllowDynamicChargeCurrent")} 17 | property VBusItem allowDynamicDischargeCurrent: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/AllowDynamicDischargeCurrent")} 18 | property VBusItem allowDynamicChargeVoltage: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/AllowDynamicChargeVoltage")} 19 | property VBusItem socLowWarning: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/SocLowWarning")} 20 | property VBusItem socLowAlarm: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/SocLowAlarm")} 21 | property VBusItem capacity: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/Capacity")} 22 | property VBusItem enableInvertedCurrent: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/EnableInvertedCurrent")} 23 | 24 | property VBusItem ccmSocLimitCharge1: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitCharge1")} 25 | property VBusItem ccmSocLimitCharge2: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitCharge2")} 26 | property VBusItem ccmSocLimitCharge3: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitCharge3")} 27 | property VBusItem ccmSocLimitDischarge1: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitDischarge1")} 28 | property VBusItem ccmSocLimitDischarge2: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitDischarge2")} 29 | property VBusItem ccmSocLimitDischarge3: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMSocLimitDischarge3")} 30 | property VBusItem ccmCurrentLimitCharge1: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitCharge1")} 31 | property VBusItem ccmCurrentLimitCharge2: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitCharge2")} 32 | property VBusItem ccmCurrentLimitCharge3: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitCharge3")} 33 | property VBusItem ccmCurrentLimitDischarge1: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitDischarge1")} 34 | property VBusItem ccmCurrentLimitDischarge2: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitDischarge2")} 35 | property VBusItem ccmCurrentLimitDischarge3: VBusItem {bind: Utils.path("com.victronenergy.settings", "/Settings/Devices/serialbattery/CCMCurrentLimitDischarge3")} 36 | 37 | title: service.description + " | Cell Voltages" 38 | 39 | model: VisibleItemModel { 40 | 41 | MbSpinBox { 42 | description: qsTr("Maximum charge current") 43 | item { 44 | bind: maxCurrentCharge.bind 45 | unit: "A" 46 | decimals: 0 47 | step: 1 48 | min: 0 49 | } 50 | } 51 | MbSpinBox { 52 | description: qsTr("Maximum discharge current") 53 | item { 54 | bind: maxCurrentDischarge.bind 55 | unit: "A" 56 | decimals: 0 57 | step: 1 58 | min: 0 59 | } 60 | } 61 | 62 | MbSpinBox { 63 | description: qsTr("Maximum cell voltage") 64 | item { 65 | bind: cellVoltageMax.bind 66 | unit: "V" 67 | decimals: 2 68 | step: 0.05 69 | } 70 | } 71 | MbSpinBox { 72 | description: qsTr("Minimum cell voltage") 73 | item { 74 | bind: cellVoltageMin.bind 75 | unit: "V" 76 | decimals: 2 77 | step: 0.05 78 | } 79 | } 80 | 81 | MbSwitch { 82 | id: allowDynamicChargeCurrentSwitch 83 | name: qsTr("Dynamic charge current") 84 | bind: allowDynamicChargeCurrent.bind 85 | } 86 | MbSwitch { 87 | id: allowDynamicDischargeCurrentSwitch 88 | name: qsTr("Dynamic discharge current") 89 | bind: allowDynamicDischargeCurrent.bind 90 | } 91 | 92 | MbSwitch { 93 | id: allowDynamicChargeVoltageSwitch 94 | name: qsTr("Dynamic charge voltage") 95 | bind : allowDynamicChargeVoltage.bind 96 | } 97 | 98 | MbSpinBox { 99 | description: qsTr("Float cell voltage") 100 | item { 101 | bind: cellVoltageFloat.bind 102 | unit: "V" 103 | decimals: 2 104 | step: 0.05 105 | } 106 | show: allowDynamicChargeVoltageSwitch.checked 107 | } 108 | 109 | MbSpinBox { 110 | description: qsTr("Low SOC Warning") 111 | item { 112 | bind: socLowWarning.bind 113 | unit: "%" 114 | decimals: 0 115 | step: 1 116 | } 117 | } 118 | MbSpinBox { 119 | description: qsTr("Low SOC Alarm") 120 | item { 121 | bind: socLowAlarm.bind 122 | unit: "%" 123 | decimals: 0 124 | step: 1 125 | } 126 | } 127 | 128 | 129 | 130 | 131 | 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /qml/PageBatteryCellVoltages.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import com.victron.velib 1.0 3 | 4 | MbPage { 5 | id: root 6 | property string bindPrefix 7 | property VBusItem _b1: VBusItem { bind: service.path("/Balances/Cell1") } 8 | property VBusItem _b2: VBusItem { bind: service.path("/Balances/Cell2") } 9 | property VBusItem _b3: VBusItem { bind: service.path("/Balances/Cell3") } 10 | property VBusItem _b4: VBusItem { bind: service.path("/Balances/Cell4") } 11 | property VBusItem _b5: VBusItem { bind: service.path("/Balances/Cell5") } 12 | property VBusItem _b6: VBusItem { bind: service.path("/Balances/Cell6") } 13 | property VBusItem _b7: VBusItem { bind: service.path("/Balances/Cell7") } 14 | property VBusItem _b8: VBusItem { bind: service.path("/Balances/Cell8") } 15 | property VBusItem _b9: VBusItem { bind: service.path("/Balances/Cell9") } 16 | property VBusItem _b10: VBusItem { bind: service.path("/Balances/Cell10") } 17 | property VBusItem _b11: VBusItem { bind: service.path("/Balances/Cell11") } 18 | property VBusItem _b12: VBusItem { bind: service.path("/Balances/Cell12") } 19 | property VBusItem _b13: VBusItem { bind: service.path("/Balances/Cell13") } 20 | property VBusItem _b14: VBusItem { bind: service.path("/Balances/Cell14") } 21 | property VBusItem _b15: VBusItem { bind: service.path("/Balances/Cell15") } 22 | property VBusItem _b16: VBusItem { bind: service.path("/Balances/Cell16") } 23 | property VBusItem _b17: VBusItem { bind: service.path("/Balances/Cell17") } 24 | property VBusItem _b18: VBusItem { bind: service.path("/Balances/Cell18") } 25 | property VBusItem _b19: VBusItem { bind: service.path("/Balances/Cell19") } 26 | property VBusItem _b20: VBusItem { bind: service.path("/Balances/Cell20") } 27 | property VBusItem _b21: VBusItem { bind: service.path("/Balances/Cell21") } 28 | property VBusItem _b22: VBusItem { bind: service.path("/Balances/Cell22") } 29 | property VBusItem _b23: VBusItem { bind: service.path("/Balances/Cell23") } 30 | property VBusItem _b24: VBusItem { bind: service.path("/Balances/Cell24") } 31 | property VBusItem volt1: VBusItem { bind: service.path("/Voltages/Cell1") } 32 | property VBusItem volt2: VBusItem { bind: service.path("/Voltages/Cell2") } 33 | property VBusItem volt3: VBusItem { bind: service.path("/Voltages/Cell3") } 34 | property VBusItem volt4: VBusItem { bind: service.path("/Voltages/Cell4") } 35 | property VBusItem volt5: VBusItem { bind: service.path("/Voltages/Cell5") } 36 | property VBusItem volt6: VBusItem { bind: service.path("/Voltages/Cell6") } 37 | property VBusItem volt7: VBusItem { bind: service.path("/Voltages/Cell7") } 38 | property VBusItem volt8: VBusItem { bind: service.path("/Voltages/Cell8") } 39 | property VBusItem volt9: VBusItem { bind: service.path("/Voltages/Cell9") } 40 | property VBusItem volt10: VBusItem { bind: service.path("/Voltages/Cell10") } 41 | property VBusItem volt11: VBusItem { bind: service.path("/Voltages/Cell11") } 42 | property VBusItem volt12: VBusItem { bind: service.path("/Voltages/Cell12") } 43 | property VBusItem volt13: VBusItem { bind: service.path("/Voltages/Cell13") } 44 | property VBusItem volt14: VBusItem { bind: service.path("/Voltages/Cell14") } 45 | property VBusItem volt15: VBusItem { bind: service.path("/Voltages/Cell15") } 46 | property VBusItem volt16: VBusItem { bind: service.path("/Voltages/Cell16") } 47 | property VBusItem volt17: VBusItem { bind: service.path("/Voltages/Cell17") } 48 | property VBusItem volt18: VBusItem { bind: service.path("/Voltages/Cell18") } 49 | property VBusItem volt19: VBusItem { bind: service.path("/Voltages/Cell19") } 50 | property VBusItem volt20: VBusItem { bind: service.path("/Voltages/Cell20") } 51 | property VBusItem volt21: VBusItem { bind: service.path("/Voltages/Cell21") } 52 | property VBusItem volt22: VBusItem { bind: service.path("/Voltages/Cell22") } 53 | property VBusItem volt23: VBusItem { bind: service.path("/Voltages/Cell23") } 54 | property VBusItem volt24: VBusItem { bind: service.path("/Voltages/Cell24") } 55 | property string c1: _b1.valid && _b1.text == "1" ? "#ff0000" : "#ddd" 56 | property string c2: _b2.valid && _b2.text == "1" ? "#ff0000" : "#ddd" 57 | property string c3: _b3.valid && _b3.text == "1" ? "#ff0000" : "#ddd" 58 | property string c4: _b4.valid && _b4.text == "1" ? "#ff0000" : "#ddd" 59 | property string c5: _b5.valid && _b5.text == "1" ? "#ff0000" : "#ddd" 60 | property string c6: _b6.valid && _b6.text == "1" ? "#ff0000" : "#ddd" 61 | property string c7: _b7.valid && _b7.text == "1" ? "#ff0000" : "#ddd" 62 | property string c8: _b8.valid && _b8.text == "1" ? "#ff0000" : "#ddd" 63 | property string c9: _b9.valid && _b9.text == "1" ? "#ff0000" : "#ddd" 64 | property string c10: _b10.valid && _b10.text == "1" ? "#ff0000" : "#ddd" 65 | property string c11: _b11.valid && _b11.text == "1" ? "#ff0000" : "#ddd" 66 | property string c12: _b12.valid && _b12.text == "1" ? "#ff0000" : "#ddd" 67 | property string c13: _b13.valid && _b13.text == "1" ? "#ff0000" : "#ddd" 68 | property string c14: _b14.valid && _b14.text == "1" ? "#ff0000" : "#ddd" 69 | property string c15: _b15.valid && _b15.text == "1" ? "#ff0000" : "#ddd" 70 | property string c16: _b16.valid && _b16.text == "1" ? "#ff0000" : "#ddd" 71 | property string c17: _b17.valid && _b17.text == "1" ? "#ff0000" : "#ddd" 72 | property string c18: _b18.valid && _b18.text == "1" ? "#ff0000" : "#ddd" 73 | property string c19: _b19.valid && _b19.text == "1" ? "#ff0000" : "#ddd" 74 | property string c20: _b20.valid && _b20.text == "1" ? "#ff0000" : "#ddd" 75 | property string c21: _b21.valid && _b21.text == "1" ? "#ff0000" : "#ddd" 76 | property string c22: _b22.valid && _b22.text == "1" ? "#ff0000" : "#ddd" 77 | property string c23: _b23.valid && _b23.text == "1" ? "#ff0000" : "#ddd" 78 | property string c24: _b24.valid && _b24.text == "1" ? "#ff0000" : "#ddd" 79 | title: service.description + " | Cell Voltages" 80 | 81 | model: VisibleItemModel { 82 | 83 | MbItemRow { 84 | description: qsTr("Cells Sum") 85 | values: [ 86 | MbTextBlock { item { bind: service.path("/Voltages/Sum") } width: 70; height: 25 } 87 | ] 88 | } 89 | MbItemRow { 90 | description: qsTr("Cells (Min/Max/Diff)") 91 | values: [ 92 | MbTextBlock { item { bind: service.path("/System/MinCellVoltage") } width: 70; height: 25 }, 93 | MbTextBlock { item { bind: service.path("/System/MaxCellVoltage") } width: 70; height: 25 }, 94 | MbTextBlock { item { bind: service.path("/Voltages/Diff") } width: 70; height: 25 } 95 | ] 96 | } 97 | MbItemRow { 98 | description: qsTr("Cells (1/2/3/4)") 99 | height: 22 100 | values: [ 101 | MbTextBlock { item: volt1; width: 70; height: 20; color: c1 }, 102 | MbTextBlock { item: volt2; width: 70; height: 20; color: c2 }, 103 | MbTextBlock { item: volt3; width: 70; height: 20; color: c3 }, 104 | MbTextBlock { item: volt4; width: 70; height: 20; color: c4 } 105 | ] 106 | } 107 | MbItemRow { 108 | description: qsTr("Cells (5/6/7/8)") 109 | height: 22 110 | show: volt5.valid 111 | values: [ 112 | MbTextBlock { item: volt5; width: 70; height: 20; color: c5 }, 113 | MbTextBlock { item: volt6; width: 70; height: 20; color: c6 }, 114 | MbTextBlock { item: volt7; width: 70; height: 20; color: c7 }, 115 | MbTextBlock { item: volt8; width: 70; height: 20; color: c8 } 116 | ] 117 | } 118 | MbItemRow { 119 | description: qsTr("Cells (9/10/11/12)") 120 | height: 22 121 | show: volt9.valid 122 | values: [ 123 | MbTextBlock { item: volt9; width: 70; height: 20; color: c9 }, 124 | MbTextBlock { item: volt10; width: 70; height: 20; color: c10 }, 125 | MbTextBlock { item: volt11; width: 70; height: 20; color: c11 }, 126 | MbTextBlock { item: volt12; width: 70; height: 20; color: c12 } 127 | ] 128 | } 129 | MbItemRow { 130 | description: qsTr("Cells (13/14/15/16)") 131 | height: 22 132 | show: volt13.valid 133 | values: [ 134 | MbTextBlock { item: volt13; width: 70; height: 20; color: c13 }, 135 | MbTextBlock { item: volt14; width: 70; height: 20; color: c14 }, 136 | MbTextBlock { item: volt15; width: 70; height: 20; color: c15 }, 137 | MbTextBlock { item: volt16; width: 70; height: 20; color: c16 } 138 | ] 139 | } 140 | MbItemRow { 141 | description: qsTr("Cells (17/18/19/20)") 142 | height: 22 143 | show: volt17.valid 144 | values: [ 145 | MbTextBlock { item: volt17; width: 70; height: 20; color: c17 }, 146 | MbTextBlock { item: volt18; width: 70; height: 20; color: c18 }, 147 | MbTextBlock { item: volt19; width: 70; height: 20; color: c19 }, 148 | MbTextBlock { item: volt20; width: 70; height: 20; color: c20 } 149 | ] 150 | } 151 | MbItemRow { 152 | description: qsTr("Cells (21/22/23/24)") 153 | height: 22 154 | show: volt21.valid 155 | values: [ 156 | MbTextBlock { item: volt21; width: 70; height: 20; color: c21 }, 157 | MbTextBlock { item: volt22; width: 70; height: 20; color: c22 }, 158 | MbTextBlock { item: volt23; width: 70; height: 20; color: c23 }, 159 | MbTextBlock { item: volt24; width: 70; height: 20; color: c24 } 160 | ] 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /jkbt.py: -------------------------------------------------------------------------------- 1 | from bluepy.btle import Peripheral, DefaultDelegate, BTLEException, BTLEDisconnectError, AssignedNumbers 2 | from threading import Thread, Lock 3 | from battery import Protection, Battery, Cell 4 | from utils import * 5 | from struct import * 6 | import argparse 7 | import sys 8 | import time 9 | import binascii 10 | import atexit 11 | 12 | 13 | OUTGOING_HEADER = b'\xaa\x55\x90\xeb' 14 | INCOMING_HEADER = b'\x55\xaa\xeb\x90' 15 | 16 | MAX_COMMAND_TIMEOUT_SECONDS = 5 17 | MAX_STATE_LIFE_SECCONDS = 5 18 | 19 | COMMAND_REQ_EXTENDED_RECORD = 0x96 20 | COMMAND_REQ_DEVICE_INFO = 0x97 21 | COMMAND_REQ_CHARGE_SWITCH = 0x1d 22 | COMMAND_REQ_DISCHARGE_SWITCH = 0x1e 23 | 24 | RESPONSE_ACK = 0xc8 25 | RESPONSE_EXTENDED_RECORD = 0x01 26 | RESPONSE_CELL_DATA = 0x02 27 | RESPONSE_DEVICE_INFO_RECORD = 0x03 28 | 29 | 30 | class JkBtDev(DefaultDelegate, Thread): 31 | def __init__(self, address): 32 | DefaultDelegate.__init__(self) 33 | Thread.__init__(self) 34 | 35 | self.incomingData = bytearray() 36 | self.address = address 37 | 38 | # Bluepy stuff 39 | self.bt = Peripheral() 40 | #self.bt.setDelegate(self) 41 | self.bt.withDelegate(self) 42 | 43 | def run(self): 44 | self.running = True 45 | timer = 0 46 | connected = False 47 | while self.running: 48 | if not connected: 49 | try: 50 | logger.info('Connecting ' + self.address) 51 | self.bt.connect(self.address, addrType="public") 52 | self.bt.setMTU(331) 53 | 54 | self.incomingData = bytearray() 55 | self.chargeSwitch = None 56 | self.dischargeSwitch = None 57 | self.commnadAcked = False 58 | 59 | #serviceJkbms = self.bt.getServiceByUUID(AssignedNumbers.genericAccess) 60 | 61 | serviceNotifyUuid = 'ffe0' 62 | serviceNotify = self.bt.getServiceByUUID(serviceNotifyUuid) 63 | 64 | characteristicConnectionUuid = 'ffe1' 65 | characteristicConnection = serviceNotify.getCharacteristics(characteristicConnectionUuid)[0] 66 | self.handleConnection = characteristicConnection.getHandle() 67 | 68 | # make subscription, dynamic search for the respective 2902 characteristics 69 | characteristicConnectionDescriptor = characteristicConnection.getDescriptors(AssignedNumbers.client_characteristic_configuration)[0] 70 | characteristicConnectionDescriptorHandle = characteristicConnectionDescriptor.handle 71 | 72 | self.bt.writeCharacteristic(characteristicConnectionDescriptorHandle, b'\x01\x00') 73 | 74 | logger.info('Connected ' + self.address) 75 | connected = True 76 | 77 | self.sendCommand(COMMAND_REQ_DEVICE_INFO) 78 | self.sendCommand(COMMAND_REQ_EXTENDED_RECORD) 79 | 80 | 81 | except BTLEException as ex: 82 | logger.info('Connection failed') 83 | time.sleep(3) 84 | continue 85 | 86 | try: 87 | 88 | if self.bt.waitForNotifications(0.5): 89 | continue 90 | 91 | except BTLEDisconnectError: 92 | logger.info('Disconnected') 93 | connected = False 94 | continue 95 | 96 | 97 | def connect(self): 98 | self.start() 99 | 100 | def stop(self): 101 | self.running = False 102 | 103 | 104 | def crc(self, data): 105 | sum = 0 106 | for b in data: 107 | sum += b 108 | 109 | return sum&0xff 110 | 111 | 112 | def readString(self, data, start, maxlen): 113 | ret = "" 114 | i = start 115 | while (i < start + maxlen) and (data[i] != 0): 116 | ret += f'{data[i]:c}' 117 | i += 1 118 | 119 | return ret 120 | 121 | 122 | 123 | def sendCommand(self, address, value=0, length=0): 124 | 125 | self.commandAcked = False 126 | 127 | frame = bytearray() 128 | frame += OUTGOING_HEADER 129 | frame += bytes([ 130 | address&0xff, # address 131 | length&0xff, # length 132 | value&0xff, (value>>8)&0xff, (value>>16)&0xff, (value>>24)&0xff # value 133 | ]) 134 | frame += bytes([ 135 | 0, 0, 0, 0, 0, 0, 0, 0, 0, # fill up to 19 Bytes 136 | self.crc(frame) # CRC8 137 | ]) 138 | 139 | log = binascii.hexlify(frame) 140 | 141 | try: 142 | self.bt.writeCharacteristic(self.handleConnection, frame) 143 | except: 144 | logger.info(f'cannot send command: {log}') 145 | return 146 | 147 | t = time.time() 148 | timeout = t + MAX_COMMAND_TIMEOUT_SECONDS 149 | while (not self.commandAcked) and (t < timeout): 150 | self.bt.waitForNotifications(timeout - t) 151 | t = time.time() 152 | 153 | 154 | 155 | 156 | def handleNotification(self, handle, data): 157 | 158 | if (data.startswith(OUTGOING_HEADER)) and (len(data) == 20): 159 | # ACK/NACK packet inside one datagram 160 | self.incomingData = data 161 | self.processData() 162 | self.incomingData = bytearray() 163 | else: 164 | if (len(self.incomingData) == 0) and (not data.startswith(INCOMING_HEADER)): 165 | # ignore wrong start 166 | d = binascii.hexlify(data) 167 | logger.info('received missaligned data: {d}') 168 | return 169 | 170 | self.incomingData += data 171 | if len(self.incomingData) == 300: 172 | self.processData() 173 | self.incomingData = bytearray() 174 | 175 | def processData(self): 176 | # check CRC8 177 | if self.crc(self.incomingData[:-1]) != self.incomingData[len(self.incomingData)-1]: 178 | # invalid CRC8 179 | d = binascii.hexlify(self.incomingData) 180 | logger.info('received packet with invaid CRC8: {d}') 181 | return 182 | 183 | address = self.incomingData[4] 184 | 185 | if address == RESPONSE_ACK: 186 | if (self.incomingData[5] == 0x01) and (self.incomingData[6] == 0x01): 187 | print("ACK") 188 | self.commandAcked = True 189 | return 190 | logger.info('received NACK') 191 | elif address == RESPONSE_DEVICE_INFO_RECORD: 192 | print("DEVICE_INFO_RECORD") 193 | 194 | deviceModel = self.readString(self.incomingData, 6, 16) 195 | hardwareVer = self.readString(self.incomingData, 22, 8) 196 | softwareVer = self.readString(self.incomingData, 30, 8) 197 | upTime = int.from_bytes(self.incomingData[38:42], byteorder='little') 198 | powerOnTimes = int.from_bytes(self.incomingData[42:46], byteorder='little') 199 | deviceName = self.readString(self.incomingData, 46, 16) 200 | devicePass = self.readString(self.incomingData, 62, 16) 201 | manufacturingDate = self.readString(self.incomingData, 78, 8) 202 | serialNum = self.readString(self.incomingData, 86, 11) 203 | password = self.readString(self.incomingData, 97, 5) 204 | userData = self.readString(self.incomingData, 102, 16) 205 | setupPass = self.readString(self.incomingData, 118, 16) 206 | 207 | 208 | print(deviceModel) 209 | print(deviceName) 210 | print(devicePass) 211 | 212 | self.name = deviceName 213 | elif address == RESPONSE_EXTENDED_RECORD: 214 | print("DEVICE_EXTENDED_RECORD") 215 | self.chargeSwitch = True if (self.incomingData[118] == 0x01) else False 216 | self.dischargeSwitch = True if (self.incomingData[122] == 0x01) else False 217 | elif address == RESPONSE_CELL_DATA: 218 | print("DEVICE_CELL_DATA") 219 | soc = self.incomingData[141] # SoC 220 | totalVol = int.from_bytes(self.incomingData[118:122], byteorder='little')/1000 # Voltage 221 | print(totalVol) 222 | cellVol = [0] * 16 223 | for i in range(0,16): 224 | cellVol[i] = int.from_bytes(self.incomingData[6+2*i:8+2*i], byteorder='little')/1000 # Cell Voltage 225 | chgCurr = int.from_bytes(self.incomingData[126:130], byteorder='little', signed=True)/1000 # Current 226 | if chgCurr < 0: 227 | disCurr = - chgCurr 228 | chgCurr = 0 229 | capacityAH = int.from_bytes(self.incomingData[142:146], byteorder='little')/1000 # remaining capacity AH 230 | cycleAH = int.from_bytes(self.incomingData[154:158], byteorder='little')/1000 # cycle AH (accumulated value over master reset) 231 | tempMOSFET = int(int.from_bytes(self.incomingData[134:136], byteorder='little', signed=True)/10) # MOSFET temperature 232 | tempT1 = int(int.from_bytes(self.incomingData[130:132], byteorder='little', signed=True)/10) # T1 temperature 233 | tempT2 = int(int.from_bytes(self.incomingData[132:134], byteorder='little', signed=True)/10) # T2 temperature 234 | chgMOSFET = self.incomingData[166] # Charging MOSFET status flag 235 | if (chgMOSFET == 0) and (not self.chargeSwitch): 236 | chgMOSFET = 15 237 | disMOSFET = self.incomingData[167] # Discharge MOSFET status flag 238 | if (disMOSFET == 0) and (not self.dischargeSwitch): 239 | disMOSFET = 15 240 | balSt = 0 if (self.incomingData[140] == 0) else 4 241 | disPower = int(int.from_bytes(self.incomingData[122:126], byteorder='little')/1000) # Power 242 | if chgCurr > 0: 243 | chgPower = disPower 244 | disPower = 0 245 | cellHigh = self.incomingData[62]+1 # Cell High number 246 | cellHighVol = cellVol[cellHigh-1] # Cell High Voltage 247 | cellLow = self.incomingData[63]+1 # Cell Low number 248 | cellLowVol = cellVol[cellLow-1] # Cell Low Voltage 249 | cellAvgVol = int.from_bytes(self.incomingData[58:60], byteorder='little')/1000 # Cell Average 250 | cellDiffVol = int.from_bytes(self.incomingData[60:62], byteorder='little')/1000 # Cell diff 251 | 252 | errorState = int.from_bytes(self.incomingData[136:138], byteorder='big') 253 | if (errorState & 0x0003 != 0) and (chgMOSFET != 1): # bit 0 - charge over temp & bit 1 - charge under temp 254 | chgMOSFET = 6 255 | if (errorState & 0x0008 != 0) and (disMOSFET != 1): # bit 3 - cell undervoltage 256 | disMOSFET = 2 257 | if (errorState & 0x0010 != 0) and (chgMOSFET != 1): # bit 4 - cell overvoltage 258 | chgMOSFET = 2 259 | if (errorState & 0x0040 != 0) and (chgMOSFET != 1): # bit 6 - charge overcurrent 260 | chgMOSFET = 3 261 | if (errorState & 0x0800 != 0): # bit 9 - current sensor annomaly 262 | ignore = 1 263 | if (errorState & 0x2000 != 0) and (disMOSFET != 1): # bit 13 - discharge over current 264 | disMOSFET = 3 265 | if (errorState & 0x8000 != 0) and (disMOSFET != 1): # bit 15 - discharge over temp 266 | disMOSFET = 6 267 | if (errorState & 0x57a4 != 0): 268 | logger.info(f'unknown system alarms: {errorState:x}') 269 | 270 | 271 | 272 | 273 | class JkBt(Battery): 274 | def __init__(self, address): 275 | Battery.__init__(self, 0, 0, address) 276 | 277 | self.protection = None 278 | self.type = "JK BT" 279 | 280 | # Bluepy stuff 281 | self.bt = Peripheral() 282 | self.bt.setDelegate(self) 283 | 284 | self.mutex = Lock() 285 | 286 | self.address = address 287 | self.port = "/bt" + address.replace(":", "") 288 | self.interval = 5 289 | 290 | dev = JkBtDev(self.address) 291 | dev.connect() 292 | 293 | 294 | def test_connection(self): 295 | return False 296 | 297 | def get_settings(self): 298 | return False 299 | 300 | def refresh_data(self): 301 | return False 302 | 303 | def log_settings(self): 304 | # Override log_settings() to call get_settings() first 305 | self.get_settings() 306 | Battery.log_settings(self) 307 | 308 | 309 | 310 | 311 | 312 | 313 | # Unit test 314 | if __name__ == "__main__": 315 | 316 | 317 | batt = JkBt( "c8:47:8c:e5:93:6c" ) 318 | 319 | batt.get_settings() 320 | 321 | while True: 322 | batt.refresh_data() 323 | 324 | 325 | print("") 326 | 327 | 328 | time.sleep(5) 329 | 330 | 331 | 332 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | import configparser 5 | from pathlib import Path 6 | from typing import List, Any, Callable 7 | 8 | from time import sleep 9 | from struct import unpack_from 10 | import bisect 11 | 12 | # Logging 13 | logging.basicConfig() 14 | logger = logging.getLogger("BluetoothBattery") 15 | logger.setLevel(logging.INFO) 16 | 17 | config = configparser.ConfigParser() 18 | path = Path(__file__).parents[0] 19 | default_config_file_path = path.joinpath("default_config.ini").absolute().__str__() 20 | custom_config_file_path = path.joinpath("config.ini").absolute().__str__() 21 | config.read([default_config_file_path, custom_config_file_path]) 22 | 23 | 24 | def _get_list_from_config( 25 | group: str, option: str, mapper: Callable[[Any], Any] = lambda v: v 26 | ) -> List[Any]: 27 | rawList = config[group][option].split(",") 28 | return list( 29 | map(mapper, [item for item in rawList if item != "" and item is not None]) 30 | ) 31 | 32 | 33 | # battery types 34 | # if not specified: baud = 9600 35 | 36 | # Constants - Need to dynamically get them in future 37 | DRIVER_VERSION = 0.1 38 | DRIVER_SUBVERSION = ".3" 39 | zero_char = chr(48) 40 | degree_sign = "\N{DEGREE SIGN}" 41 | 42 | # Choose the mode for voltage / current limitations (True / False) 43 | # False is a Step mode. This is the default with limitations on hard boundary steps 44 | # True "Linear" # New linear limitations by WaldemarFech for smoother values 45 | LINEAR_LIMITATION_ENABLE = "True" == config["DEFAULT"]["LINEAR_LIMITATION_ENABLE"] 46 | 47 | # battery Current limits 48 | MAX_BATTERY_CHARGE_CURRENT = float(config["DEFAULT"]["MAX_BATTERY_CHARGE_CURRENT"]) 49 | MAX_BATTERY_DISCHARGE_CURRENT = float( 50 | config["DEFAULT"]["MAX_BATTERY_DISCHARGE_CURRENT"] 51 | ) 52 | 53 | # -------- Cell Voltage limitation --------- 54 | # Description: 55 | # Maximal charge / discharge current will be in-/decreased depending on min- and max-cell-voltages 56 | # Example: 18cells * 3.55V/cell = 63.9V max charge voltage. 18 * 2.7V = 48,6V min discharge voltage 57 | # ... but the (dis)charge current will be (in-/)decreased, if even ONE SINGLE BATTERY CELL reaches the limits 58 | 59 | # Charge current control management referring to cell-voltage enable (True/False). 60 | CCCM_CV_ENABLE = "True" == config["DEFAULT"]["CCCM_CV_ENABLE"] 61 | # Discharge current control management referring to cell-voltage enable (True/False). 62 | DCCM_CV_ENABLE = "True" == config["DEFAULT"]["DCCM_CV_ENABLE"] 63 | 64 | # Set Steps to reduce battery current. The current will be changed linear between those steps 65 | CELL_VOLTAGES_WHILE_CHARGING = _get_list_from_config( 66 | "DEFAULT", "CELL_VOLTAGES_WHILE_CHARGING", lambda v: float(v) 67 | ) 68 | MAX_CHARGE_CURRENT_CV = _get_list_from_config( 69 | "DEFAULT", 70 | "MAX_CHARGE_CURRENT_CV_FRACTION", 71 | lambda v: MAX_BATTERY_CHARGE_CURRENT * float(v), 72 | ) 73 | 74 | CELL_VOLTAGES_WHILE_DISCHARGING = _get_list_from_config( 75 | "DEFAULT", "CELL_VOLTAGES_WHILE_DISCHARGING", lambda v: float(v) 76 | ) 77 | MAX_DISCHARGE_CURRENT_CV = _get_list_from_config( 78 | "DEFAULT", 79 | "MAX_DISCHARGE_CURRENT_CV_FRACTION", 80 | lambda v: MAX_BATTERY_DISCHARGE_CURRENT * float(v), 81 | ) 82 | 83 | # -------- Temperature limitation --------- 84 | # Description: 85 | # Maximal charge / discharge current will be in-/decreased depending on temperature 86 | # Example: The temperature limit will be monitored to control the currents. If there are two temperature senors, 87 | # then the worst case will be calculated and the more secure lower current will be set. 88 | # Charge current control management referring to temperature enable (True/False). 89 | CCCM_T_ENABLE = "True" == config["DEFAULT"]["CCCM_T_ENABLE"] 90 | # Charge current control management referring to temperature enable (True/False). 91 | DCCM_T_ENABLE = "True" == config["DEFAULT"]["DCCM_T_ENABLE"] 92 | 93 | # Set Steps to reduce battery current. The current will be changed linear between those steps 94 | TEMPERATURE_LIMITS_WHILE_CHARGING = _get_list_from_config( 95 | "DEFAULT", "TEMPERATURE_LIMITS_WHILE_CHARGING", lambda v: float(v) 96 | ) 97 | MAX_CHARGE_CURRENT_T = _get_list_from_config( 98 | "DEFAULT", 99 | "MAX_CHARGE_CURRENT_T_FRACTION", 100 | lambda v: MAX_BATTERY_CHARGE_CURRENT * float(v), 101 | ) 102 | 103 | TEMPERATURE_LIMITS_WHILE_DISCHARGING = _get_list_from_config( 104 | "DEFAULT", "TEMPERATURE_LIMITS_WHILE_DISCHARGING", lambda v: float(v) 105 | ) 106 | MAX_DISCHARGE_CURRENT_T = _get_list_from_config( 107 | "DEFAULT", 108 | "MAX_DISCHARGE_CURRENT_T_FRACTION", 109 | lambda v: MAX_BATTERY_DISCHARGE_CURRENT * float(v), 110 | ) 111 | 112 | # if the cell voltage reaches 3.55V, then reduce current battery-voltage by 0.01V 113 | # if the cell voltage goes over 3.6V, then the maximum penalty will not be exceeded 114 | # there will be a sum of all penalties for each cell, which exceeds the limits 115 | PENALTY_AT_CELL_VOLTAGE = _get_list_from_config( 116 | "DEFAULT", "PENALTY_AT_CELL_VOLTAGE", lambda v: float(v) 117 | ) 118 | PENALTY_BATTERY_VOLTAGE = _get_list_from_config( 119 | "DEFAULT", "PENALTY_BATTERY_VOLTAGE", lambda v: float(v) 120 | ) 121 | 122 | 123 | # -------- SOC limitation --------- 124 | # Description: 125 | # Maximal charge / discharge current will be increased / decreased depending on State of Charge, see CC_SOC_LIMIT1 etc. 126 | # The State of Charge (SoC) charge / discharge current will be in-/decreased depending on SOC. 127 | # Example: 16cells * 3.45V/cell = 55,2V max charge voltage. 16*2.9V = 46,4V min discharge voltage 128 | # Cell min/max voltages - used with the cell count to get the min/max battery voltage 129 | MIN_CELL_VOLTAGE = float(config["DEFAULT"]["MIN_CELL_VOLTAGE"]) 130 | MAX_CELL_VOLTAGE = float(config["DEFAULT"]["MAX_CELL_VOLTAGE"]) 131 | FLOAT_CELL_VOLTAGE = float(config["DEFAULT"]["FLOAT_CELL_VOLTAGE"]) 132 | MAX_VOLTAGE_TIME_SEC = float(config["DEFAULT"]["MAX_VOLTAGE_TIME_SEC"]) 133 | SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = float( 134 | config["DEFAULT"]["SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT"] 135 | ) 136 | 137 | # Charge current control management enable (True/False). 138 | CCCM_SOC_ENABLE = "True" == config["DEFAULT"]["CCCM_SOC_ENABLE"] 139 | # Discharge current control management enable (True/False). 140 | DCCM_SOC_ENABLE = "True" == config["DEFAULT"]["DCCM_SOC_ENABLE"] 141 | 142 | # charge current soc limits 143 | CC_SOC_LIMIT1 = float(config["DEFAULT"]["CC_SOC_LIMIT1"]) 144 | CC_SOC_LIMIT2 = float(config["DEFAULT"]["CC_SOC_LIMIT2"]) 145 | CC_SOC_LIMIT3 = float(config["DEFAULT"]["CC_SOC_LIMIT3"]) 146 | 147 | # charge current limits 148 | CC_CURRENT_LIMIT1 = MAX_BATTERY_CHARGE_CURRENT * float( 149 | config["DEFAULT"]["CC_CURRENT_LIMIT1_FRACTION"] 150 | ) 151 | CC_CURRENT_LIMIT2 = MAX_BATTERY_CHARGE_CURRENT * float( 152 | config["DEFAULT"]["CC_CURRENT_LIMIT2_FRACTION"] 153 | ) 154 | CC_CURRENT_LIMIT3 = MAX_BATTERY_CHARGE_CURRENT * float( 155 | config["DEFAULT"]["CC_CURRENT_LIMIT3_FRACTION"] 156 | ) 157 | 158 | # discharge current soc limits 159 | DC_SOC_LIMIT1 = float(config["DEFAULT"]["DC_SOC_LIMIT1"]) 160 | DC_SOC_LIMIT2 = float(config["DEFAULT"]["DC_SOC_LIMIT2"]) 161 | DC_SOC_LIMIT3 = float(config["DEFAULT"]["DC_SOC_LIMIT3"]) 162 | 163 | # discharge current limits 164 | DC_CURRENT_LIMIT1 = MAX_BATTERY_DISCHARGE_CURRENT * float( 165 | config["DEFAULT"]["DC_CURRENT_LIMIT1_FRACTION"] 166 | ) 167 | DC_CURRENT_LIMIT2 = MAX_BATTERY_DISCHARGE_CURRENT * float( 168 | config["DEFAULT"]["DC_CURRENT_LIMIT2_FRACTION"] 169 | ) 170 | DC_CURRENT_LIMIT3 = MAX_BATTERY_DISCHARGE_CURRENT * float( 171 | config["DEFAULT"]["DC_CURRENT_LIMIT3_FRACTION"] 172 | ) 173 | 174 | # Charge voltage control management enable (True/False). 175 | CVCM_ENABLE = "True" == config["DEFAULT"]["CVCM_ENABLE"] 176 | 177 | # Simulate Midpoint graph (True/False). 178 | MIDPOINT_ENABLE = "True" == config["DEFAULT"]["MIDPOINT_ENABLE"] 179 | 180 | # soc low levels 181 | SOC_LOW_WARNING = float(config["DEFAULT"]["SOC_LOW_WARNING"]) 182 | SOC_LOW_ALARM = float(config["DEFAULT"]["SOC_LOW_ALARM"]) 183 | 184 | # Daly settings 185 | # Battery capacity (amps) if the BMS does not support reading it 186 | BATTERY_CAPACITY = float(config["DEFAULT"]["BATTERY_CAPACITY"]) 187 | # Invert Battery Current. Default non-inverted. Set to -1 to invert 188 | INVERT_CURRENT_MEASUREMENT = int(config["DEFAULT"]["INVERT_CURRENT_MEASUREMENT"]) 189 | 190 | # TIME TO SOC settings [Valid values 0-100, but I don't recommend more that 20 intervals] 191 | # Set of SoC percentages to report on dbus. The more you specify the more it will impact system performance. 192 | # TIME_TO_SOC_POINTS = [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] 193 | # Every 5% SoC 194 | # TIME_TO_SOC_POINTS = [100, 95, 90, 85, 75, 50, 25, 20, 10, 0] 195 | TIME_TO_SOC_POINTS = _get_list_from_config("DEFAULT", "TIME_TO_SOC_POINTS") 196 | # Specify TimeToSoc value type: [Valid values 1,2,3] 197 | # TIME_TO_SOC_VALUE_TYPE = 1 # Seconds 198 | # TIME_TO_SOC_VALUE_TYPE = 2 # Time string HH:MN:SC 199 | TIME_TO_SOC_VALUE_TYPE = int(config["DEFAULT"]["TIME_TO_SOC_VALUE_TYPE"]) 200 | # Specify how many loop cycles between each TimeToSoc updates 201 | TIME_TO_SOC_LOOP_CYCLES = int(config["DEFAULT"]["TIME_TO_SOC_LOOP_CYCLES"]) 202 | # Include TimeToSoC points when moving away from the SoC point. [Valid values True,False] 203 | # These will be as negative time. Disabling this improves performance slightly. 204 | TIME_TO_SOC_INC_FROM = "True" == config["DEFAULT"]["TIME_TO_SOC_INC_FROM"] 205 | 206 | 207 | # Select the format of cell data presented on dbus. [Valid values 0,1,2,3] 208 | # 0 Do not publish all the cells (only the min/max cell data as used by the default GX) 209 | # 1 Format: /Voltages/Cell# (also available for display on Remote Console) 210 | # 2 Format: /Cell/#/Volts 211 | # 3 Both formats 1 and 2 212 | BATTERY_CELL_DATA_FORMAT = int(config["DEFAULT"]["BATTERY_CELL_DATA_FORMAT"]) 213 | 214 | # Settings for ESC GreenMeter and Lipro devices 215 | GREENMETER_ADDRESS = int(config["DEFAULT"]["GREENMETER_ADDRESS"]) 216 | LIPRO_START_ADDRESS = int(config["DEFAULT"]["LIPRO_START_ADDRESS"]) 217 | LIPRO_END_ADDRESS = int(config["DEFAULT"]["LIPRO_END_ADDRESS"]) 218 | LIPRO_CELL_COUNT = int(config["DEFAULT"]["LIPRO_CELL_COUNT"]) 219 | 220 | PUBLISH_CONFIG_VALUES = int(config["DEFAULT"]["PUBLISH_CONFIG_VALUES"]) 221 | 222 | BMS_TYPE = config["DEFAULT"]["BMS_TYPE"] 223 | 224 | 225 | def constrain(val, min_val, max_val): 226 | if min_val > max_val: 227 | min_val, max_val = max_val, min_val 228 | return min(max_val, max(min_val, val)) 229 | 230 | 231 | def mapRange(inValue, inMin, inMax, outMin, outMax): 232 | return outMin + (((inValue - inMin) / (inMax - inMin)) * (outMax - outMin)) 233 | 234 | 235 | def mapRangeConstrain(inValue, inMin, inMax, outMin, outMax): 236 | return constrain(mapRange(inValue, inMin, inMax, outMin, outMax), outMin, outMax) 237 | 238 | 239 | def calcLinearRelationship(inValue, inArray, outArray): 240 | if inArray[0] > inArray[-1]: # change compare-direction in array 241 | return calcLinearRelationship(inValue, inArray[::-1], outArray[::-1]) 242 | else: 243 | 244 | # Handle out of bounds 245 | if inValue <= inArray[0]: 246 | return outArray[0] 247 | if inValue >= inArray[-1]: 248 | return outArray[-1] 249 | 250 | # else calculate linear current between the setpoints 251 | idx = bisect.bisect(inArray, inValue) 252 | upperIN = inArray[idx - 1] # begin with idx 0 as max value 253 | upperOUT = outArray[idx - 1] 254 | lowerIN = inArray[idx] 255 | lowerOUT = outArray[idx] 256 | return mapRangeConstrain(inValue, lowerIN, upperIN, lowerOUT, upperOUT) 257 | 258 | 259 | def calcStepRelationship(inValue, inArray, outArray, returnLower): 260 | if inArray[0] > inArray[-1]: # change compare-direction in array 261 | return calcStepRelationship(inValue, inArray[::-1], outArray[::-1], returnLower) 262 | 263 | # Handle out of bounds 264 | if inValue <= inArray[0]: 265 | return outArray[0] 266 | if inValue >= inArray[-1]: 267 | return outArray[-1] 268 | 269 | # else get index between the setpoints 270 | idx = bisect.bisect(inArray, inValue) 271 | 272 | return outArray[idx] if returnLower else outArray[idx - 1] 273 | 274 | 275 | def is_bit_set(tmp): 276 | return False if tmp == zero_char else True 277 | 278 | 279 | def kelvin_to_celsius(kelvin_temp): 280 | return kelvin_temp - 273.1 281 | 282 | 283 | def format_value(value, prefix, suffix): 284 | return ( 285 | None 286 | if value is None 287 | else ("" if prefix is None else prefix) 288 | + str(value) 289 | + ("" if suffix is None else suffix) 290 | ) 291 | 292 | 293 | 294 | 295 | locals_copy = locals().copy() 296 | 297 | 298 | def publish_config_variables(dbusservice): 299 | for variable, value in locals_copy.items(): 300 | if variable.startswith("__"): 301 | continue 302 | if ( 303 | isinstance(value, float) 304 | or isinstance(value, int) 305 | or isinstance(value, str) 306 | or isinstance(value, List) 307 | ): 308 | dbusservice.add_path(f"/Info/Config/{variable}", value) 309 | -------------------------------------------------------------------------------- /jbdbt.py: -------------------------------------------------------------------------------- 1 | from bluepy.btle import Peripheral, DefaultDelegate, BTLEException, BTLEDisconnectError 2 | from threading import Thread, Lock 3 | from battery import Protection, Battery, Cell 4 | from utils import * 5 | from struct import * 6 | import argparse 7 | import sys 8 | import time 9 | import binascii 10 | import atexit 11 | import os 12 | 13 | # 0 disabled, or set the number of seconds to detect BT hang, and reboot. 14 | BT_WATCHDOG_TIMER=300 15 | 16 | 17 | class JbdProtection(Protection): 18 | def __init__(self): 19 | Protection.__init__(self) 20 | self.voltage_high_cell = False 21 | self.voltage_low_cell = False 22 | self.short = False 23 | self.IC_inspection = False 24 | self.software_lock = False 25 | 26 | def set_voltage_high_cell(self, value): 27 | self.voltage_high_cell = value 28 | self.cell_imbalance = ( 29 | 2 if self.voltage_low_cell or self.voltage_high_cell else 0 30 | ) 31 | 32 | def set_voltage_low_cell(self, value): 33 | self.voltage_low_cell = value 34 | self.cell_imbalance = ( 35 | 2 if self.voltage_low_cell or self.voltage_high_cell else 0 36 | ) 37 | 38 | def set_short(self, value): 39 | self.short = value 40 | self.set_cell_imbalance( 41 | 2 if self.short or self.IC_inspection or self.software_lock else 0 42 | ) 43 | 44 | def set_ic_inspection(self, value): 45 | self.IC_inspection = value 46 | self.set_cell_imbalance( 47 | 2 if self.short or self.IC_inspection or self.software_lock else 0 48 | ) 49 | 50 | def set_software_lock(self, value): 51 | self.software_lock = value 52 | self.set_cell_imbalance( 53 | 2 if self.short or self.IC_inspection or self.software_lock else 0 54 | ) 55 | 56 | 57 | 58 | class JbdBtDev(DefaultDelegate, Thread): 59 | def __init__(self, address): 60 | DefaultDelegate.__init__(self) 61 | Thread.__init__(self) 62 | 63 | self.cellDataCallback = None 64 | self.cellData = None 65 | self.cellDataTotalLen = 0 66 | self.cellDataRemainingLen = 0 67 | self.last_state = "0000" 68 | 69 | self.generalDataCallback = None 70 | self.generalData = None 71 | self.generalDataTotalLen = 0 72 | self.generalDataRemainingLen = 0 73 | 74 | self.address = address 75 | self.interval = 5 76 | 77 | # Bluepy stuff 78 | self.bt = Peripheral() 79 | self.bt.setDelegate(self) 80 | 81 | 82 | def reset(self): 83 | self.last_state = "0000" 84 | self.cellDataTotalLen = 0 85 | self.cellDataRemainingLen = 0 86 | self.generalDataTotalLen = 0 87 | self.generalDataRemainingLen = 0 88 | 89 | 90 | def run(self): 91 | self.running = True 92 | timer = 0 93 | connected = False 94 | while self.running: 95 | if not connected: 96 | try: 97 | logger.info('Connecting ' + self.address) 98 | self.bt.connect(self.address, addrType="public") 99 | logger.info('Connected ' + self.address) 100 | connected = True 101 | self.reset() 102 | except BTLEException as ex: 103 | logger.info('Connection failed: ' + str(ex)) 104 | time.sleep(3) 105 | continue 106 | 107 | try: 108 | if self.bt.waitForNotifications(2): 109 | continue 110 | 111 | if (time.monotonic() - timer) > self.interval: 112 | timer = time.monotonic() 113 | result = self.bt.writeCharacteristic(0x15, b'\xdd\xa5\x03\x00\xff\xfd\x77', True) # write x03 (general info) 114 | #time.sleep(1) # Need time between writes? 115 | while self.bt.waitForNotifications(1): 116 | continue 117 | result = self.bt.writeCharacteristic(0x15, b'\xdd\xa5\x04\x00\xff\xfc\x77', True) # write x04 (cell voltages) 118 | 119 | 120 | except BTLEDisconnectError: 121 | logger.info('Disconnected') 122 | connected = False 123 | continue 124 | 125 | 126 | def connect(self): 127 | self.daemon=True 128 | self.start() 129 | 130 | def stop(self): 131 | self.running = False 132 | 133 | def addCellDataCallback(self, func): 134 | self.cellDataCallback = func 135 | 136 | def addGeneralDataCallback(self, func): 137 | self.generalDataCallback = func 138 | 139 | def handleNotification(self, cHandle, data): 140 | if data is None: 141 | logger.info("data is None") 142 | return 143 | 144 | hex_data = binascii.hexlify(data) 145 | hex_string = hex_data.decode('utf-8') 146 | #logger.info("new Hex_String(" +str(len(data))+"): " + str(hex_string)) 147 | 148 | 149 | HEADER_LEN = 4 #[Start Code][Command][Status][Length] 150 | FOOTER_LEN = 3 #[16bit Checksum][Stop Code] 151 | 152 | # Route incoming BMS data 153 | 154 | # Cell Data 155 | if hex_string.find('dd04') != -1: 156 | self.last_state = "dd04" 157 | # Because of small MTU size, the BMS data may not be transmitted in a single packet. 158 | # We use the 4th byte defined as "data len" in the BMS protocol to calculate the remaining bytes 159 | # that will be transmitted in the second packet 160 | self.cellDataTotalLen = data[3] + HEADER_LEN + FOOTER_LEN 161 | self.cellDataRemainingLen = self.cellDataTotalLen - len(data) 162 | logger.info("cellDataTotalLen: " + str(int(self.cellDataTotalLen))) 163 | #logger.info("cellDataRemainingLen: " + str(int(self.cellDataRemainingLen))) 164 | self.cellData = data 165 | elif self.last_state == "dd04" and hex_string.find('dd04') == -1 and hex_string.find('dd03') == -1: 166 | self.cellData = self.cellData + data 167 | 168 | # General Data 169 | elif hex_string.find('dd03') != -1: 170 | self.last_state = "dd03" 171 | self.generalDataTotalLen = data[3] + HEADER_LEN + FOOTER_LEN 172 | self.generalDataRemainingLen = self.generalDataTotalLen - len(data) 173 | logger.info("generalDataTotalLen: " + str(int(self.generalDataTotalLen))) 174 | #logger.info("generalDataRemainingLen: " + str(int(self.generalDataRemainingLen))) 175 | self.generalData = data 176 | elif self.last_state == "dd03" and hex_string.find('dd04') == -1 and hex_string.find('dd03') == -1: 177 | self.generalData = self.generalData + data 178 | 179 | if self.last_state == "dd04" and self.cellData and len(self.cellData) == self.cellDataTotalLen: 180 | self.cellDataCallback(self.cellData) 181 | logger.info("cellData(" + str(len(self.cellData))+ "): " + str(binascii.hexlify(self.cellData).decode('utf-8'))) 182 | self.last_state == "0000" 183 | self.cellData = None 184 | 185 | if self.last_state == "dd03" and self.generalData and len(self.generalData) == self.generalDataTotalLen: 186 | self.generalDataCallback(self.generalData) 187 | logger.info("generalData(" + str(len(self.generalData)) + "): " + str(binascii.hexlify(self.generalData).decode('utf-8'))) 188 | self.last_state == "0000" 189 | self.generalData = None 190 | 191 | class JbdBt(Battery): 192 | def __init__(self, address): 193 | Battery.__init__(self, 0, 0, address) 194 | 195 | self.protection = JbdProtection() 196 | self.type = "JBD BT" 197 | 198 | # Bluepy stuff 199 | self.bt = Peripheral() 200 | self.bt.setDelegate(self) 201 | 202 | self.mutex = Lock() 203 | self.generalData = None 204 | self.generalDataTS = time.monotonic() 205 | self.cellData = None 206 | self.cellDataTS = time.monotonic() 207 | 208 | self.address = address 209 | self.port = "/bt" + address.replace(":", "") 210 | self.interval = 5 211 | 212 | dev = JbdBtDev(self.address) 213 | dev.addCellDataCallback(self.cellDataCB) 214 | dev.addGeneralDataCallback(self.generalDataCB) 215 | dev.connect() 216 | 217 | 218 | def test_connection(self): 219 | return False 220 | 221 | def get_settings(self): 222 | result = self.read_gen_data() 223 | while not result: 224 | result = self.read_gen_data() 225 | time.sleep(1) 226 | self.max_battery_charge_current = MAX_BATTERY_CHARGE_CURRENT 227 | self.max_battery_discharge_current = MAX_BATTERY_DISCHARGE_CURRENT 228 | return result 229 | 230 | def refresh_data(self): 231 | result = self.read_gen_data() 232 | result = result and self.read_cell_data() 233 | return result 234 | 235 | def log_settings(self): 236 | # Override log_settings() to call get_settings() first 237 | self.get_settings() 238 | Battery.log_settings(self) 239 | 240 | def to_protection_bits(self, byte_data): 241 | tmp = bin(byte_data)[2:].rjust(13, zero_char) 242 | 243 | self.protection.voltage_high = 2 if is_bit_set(tmp[10]) else 0 244 | self.protection.voltage_low = 2 if is_bit_set(tmp[9]) else 0 245 | self.protection.temp_high_charge = 1 if is_bit_set(tmp[8]) else 0 246 | self.protection.temp_low_charge = 1 if is_bit_set(tmp[7]) else 0 247 | self.protection.temp_high_discharge = 1 if is_bit_set(tmp[6]) else 0 248 | self.protection.temp_low_discharge = 1 if is_bit_set(tmp[5]) else 0 249 | self.protection.current_over = 1 if is_bit_set(tmp[4]) else 0 250 | self.protection.current_under = 1 if is_bit_set(tmp[3]) else 0 251 | 252 | # Software implementations for low soc 253 | self.protection.soc_low = ( 254 | 2 if self.soc < SOC_LOW_ALARM else 1 if self.soc < SOC_LOW_WARNING else 0 255 | ) 256 | 257 | # extra protection flags for LltJbd 258 | self.protection.set_voltage_low_cell = is_bit_set(tmp[11]) 259 | self.protection.set_voltage_high_cell = is_bit_set(tmp[12]) 260 | self.protection.set_software_lock = is_bit_set(tmp[0]) 261 | self.protection.set_IC_inspection = is_bit_set(tmp[1]) 262 | self.protection.set_short = is_bit_set(tmp[2]) 263 | 264 | def to_cell_bits(self, byte_data, byte_data_high): 265 | # clear the list 266 | #for c in self.cells: 267 | # self.cells.remove(c) 268 | self.cells: List[Cell] = [] 269 | 270 | # get up to the first 16 cells 271 | tmp = bin(byte_data)[2:].rjust(min(self.cell_count, 16), zero_char) 272 | for bit in reversed(tmp): 273 | self.cells.append(Cell(is_bit_set(bit))) 274 | 275 | # get any cells above 16 276 | if self.cell_count > 16: 277 | tmp = bin(byte_data_high)[2:].rjust(self.cell_count - 16, zero_char) 278 | for bit in reversed(tmp): 279 | self.cells.append(Cell(is_bit_set(bit))) 280 | 281 | def to_fet_bits(self, byte_data): 282 | tmp = bin(byte_data)[2:].rjust(2, zero_char) 283 | self.charge_fet = is_bit_set(tmp[1]) 284 | self.discharge_fet = is_bit_set(tmp[0]) 285 | 286 | def read_gen_data(self): 287 | self.mutex.acquire() 288 | self.checkTS(self.generalDataTS) 289 | 290 | if self.generalData == None: 291 | self.mutex.release() 292 | return False 293 | 294 | gen_data = self.generalData[4:] 295 | self.mutex.release() 296 | 297 | if len(gen_data) < 27: 298 | return False 299 | 300 | ( 301 | voltage, 302 | current, 303 | capacity_remain, 304 | capacity, 305 | self.cycles, 306 | self.production, 307 | balance, 308 | balance2, 309 | protection, 310 | version, 311 | self.soc, 312 | fet, 313 | self.cell_count, 314 | self.temp_sensors, 315 | ) = unpack_from(">HhHHHHhHHBBBBB", gen_data, 0) 316 | self.voltage = voltage / 100 317 | self.current = current / 100 318 | self.capacity_remain = capacity_remain / 100 319 | self.capacity = capacity / 100 320 | self.to_cell_bits(balance, balance2) 321 | self.version = float(str(version >> 4 & 0x0F) + "." + str(version & 0x0F)) 322 | self.to_fet_bits(fet) 323 | self.to_protection_bits(protection) 324 | self.max_battery_voltage = MAX_CELL_VOLTAGE * self.cell_count 325 | self.min_battery_voltage = MIN_CELL_VOLTAGE * self.cell_count 326 | 327 | for t in range(self.temp_sensors): 328 | temp1 = unpack_from(">H", gen_data, 23 + (2 * t))[0] 329 | self.to_temp(t + 1, kelvin_to_celsius(temp1 / 10)) 330 | 331 | return True 332 | 333 | def read_cell_data(self): 334 | self.mutex.acquire() 335 | self.checkTS(self.cellDataTS) 336 | 337 | if self.cellData == None: 338 | self.mutex.release() 339 | return False 340 | 341 | cell_data = self.cellData[4:] 342 | self.mutex.release() 343 | 344 | if len(cell_data) < self.cell_count * 2: 345 | return False 346 | 347 | for c in range(self.cell_count): 348 | try: 349 | cell_volts = unpack_from(">H", cell_data, c * 2) 350 | if len(cell_volts) != 0: 351 | self.cells[c].voltage = cell_volts[0] / 1000 352 | except struct.error: 353 | self.cells[c].voltage = 0 354 | 355 | return True 356 | 357 | def cellDataCB(self, data): 358 | self.mutex.acquire() 359 | self.cellData = data 360 | self.cellDataTS = time.monotonic() 361 | self.mutex.release() 362 | 363 | def generalDataCB(self, data): 364 | self.mutex.acquire() 365 | self.generalData = data 366 | self.generalDataTS = time.monotonic() 367 | self.mutex.release() 368 | 369 | def checkTS(self, ts): 370 | elapsed = 0 371 | if ts: 372 | elapsed = time.monotonic() - ts 373 | 374 | #if (int(elapsed) % 60) == 0: 375 | # logger.info(elapsed) 376 | 377 | if BT_WATCHDOG_TIMER == 0: 378 | return 379 | 380 | if elapsed > BT_WATCHDOG_TIMER: 381 | logger.info('Watchdog timer expired. BT chipset might be locked up. Rebooting') 382 | os.system('reboot') 383 | 384 | 385 | # Unit test 386 | if __name__ == "__main__": 387 | 388 | 389 | batt = JbdBt( "70:3e:97:07:e0:dd" ) 390 | #batt = JbdBt( "70:3e:97:07:e0:d9" ) 391 | #batt = JbdBt( "e0:9f:2a:fd:29:26" ) 392 | #batt = JbdBt( "70:3e:97:08:00:62" ) 393 | #batt = JbdBt( "a4:c1:37:40:89:5e" ) 394 | #batt = JbdBt( "a4:c1:37:00:25:91" ) 395 | batt.get_settings() 396 | 397 | while True: 398 | batt.refresh_data() 399 | print("Cells " + str(batt.cell_count) ) 400 | for c in range(batt.cell_count): 401 | print( str(batt.cells[c].voltage) + "v", end=" " ) 402 | print("") 403 | time.sleep(5) 404 | 405 | 406 | -------------------------------------------------------------------------------- /qml/PageBattery.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 1.1 2 | import com.victron.velib 1.0 3 | 4 | MbPage { 5 | id: root 6 | 7 | property variant service 8 | property string bindPrefix 9 | 10 | property VBusItem hasSettings: VBusItem { bind: service.path("/Settings/HasSettings") } 11 | property VBusItem dcVoltage: VBusItem { bind: service.path("/Dc/0/Voltage") } 12 | property VBusItem dcCurrent: VBusItem { bind: service.path("/Dc/0/Current") } 13 | property VBusItem midVoltage: VBusItem { bind: service.path("/Dc/0/MidVoltage") } 14 | property VBusItem productId: VBusItem { bind: service.path("/ProductId") } 15 | property VBusItem cell1: VBusItem { bind: service.path("/Voltages/Cell1") } 16 | property VBusItem nrOfDistributors: VBusItem { bind: service.path("/NrOfDistributors") } 17 | 18 | property PageLynxDistributorList distributorListPage 19 | 20 | property bool isFiamm48TL: productId.value === 0xB012 21 | property int numberOfDistributors: nrOfDistributors.valid ? nrOfDistributors.value : 0 22 | 23 | title: service.description 24 | summary: [soc.item.format(0), dcVoltage.text, dcCurrent.text] 25 | 26 | /* PageLynxDistributorList cannot use Component for its subpages, because of the summary. 27 | * Therefor create it upon reception of /NrOfDistributors instead of when accessing the page 28 | * to prevent a ~3s loading time. */ 29 | onNumberOfDistributorsChanged: { 30 | if (distributorListPage == undefined && numberOfDistributors > 0) { 31 | distributorListPage = distributorPageComponent.createObject(root) 32 | } 33 | } 34 | 35 | Component { 36 | id: distributorPageComponent 37 | PageLynxDistributorList { 38 | bindPrefix: service.path("") 39 | } 40 | } 41 | 42 | model: VisibleItemModel { 43 | MbItemOptions { 44 | description: qsTr("Switch") 45 | bind: service.path("/Mode") 46 | show: item.valid 47 | 48 | possibleValues: [ 49 | MbOption { description: qsTr("Off"); value: 4; readonly: true }, 50 | MbOption { description: qsTr("Standby"); value: 0xfc }, 51 | MbOption { description: qsTr("On"); value: 3 } 52 | ] 53 | } 54 | 55 | MbItemOptions { 56 | description: qsTr("State") 57 | bind: service.path("/State") 58 | readonly: true 59 | show: item.valid 60 | possibleValues:[ 61 | MbOption { description: qsTr("Initializing"); value: 0 }, 62 | MbOption { description: qsTr("Initializing"); value: 1 }, 63 | MbOption { description: qsTr("Initializing"); value: 2 }, 64 | MbOption { description: qsTr("Initializing"); value: 3 }, 65 | MbOption { description: qsTr("Initializing"); value: 4 }, 66 | MbOption { description: qsTr("Initializing"); value: 5 }, 67 | MbOption { description: qsTr("Initializing"); value: 6 }, 68 | MbOption { description: qsTr("Initializing"); value: 7 }, 69 | MbOption { description: qsTr("Initializing"); value: 8 }, 70 | MbOption { description: qsTr("Running"); value: 9 }, 71 | MbOption { description: qsTr("Error"); value: 10 }, 72 | // MbOption { description: qsTr("Unknown"); value: 11 }, 73 | MbOption { description: qsTr("Shutdown"); value: 12 }, 74 | MbOption { description: qsTr("Updating"); value: 13 }, 75 | MbOption { description: qsTr("Standby"); value: 14 }, 76 | MbOption { description: qsTr("Going to run"); value: 15 }, 77 | MbOption { description: qsTr("Pre-Charging"); value: 16 }, 78 | MbOption { description: qsTr("Contactor check"); value: 17 } 79 | ] 80 | } 81 | 82 | MbItemBmsError { 83 | description: qsTr("Error") 84 | item.bind: service.path("/ErrorCode") 85 | show: item.valid 86 | } 87 | 88 | MbItemRow { 89 | description: qsTr("Battery") 90 | values: [ 91 | MbTextBlock { item: dcVoltage; width: 90; height: 25 }, 92 | MbTextBlock { item: dcCurrent; width: 90; height: 25 }, 93 | MbTextBlock { item.bind: service.path("/Dc/0/Power"); width: 90; height: 25 } 94 | ] 95 | } 96 | 97 | MbItemValue { 98 | id: soc 99 | 100 | description: qsTr("State of charge") 101 | item { 102 | bind: service.path("/Soc") 103 | unit: "%" 104 | } 105 | } 106 | 107 | MbItemValue { 108 | description: qsTr("State of health") 109 | item.bind: service.path("/Soh") 110 | show: item.valid 111 | } 112 | 113 | MbItemValue { 114 | description: qsTr("Battery temperature") 115 | show: item.valid 116 | item { 117 | bind: service.path("/Dc/0/Temperature") 118 | displayUnit: user.temperatureUnit 119 | } 120 | } 121 | 122 | MbItemValue { 123 | description: qsTr("MOSFET temperature") 124 | show: item.valid 125 | item { 126 | bind: service.path("/System/MOSTemperature") 127 | displayUnit: user.temperatureUnit 128 | } 129 | } 130 | 131 | MbItemValue { 132 | description: qsTr("Air temperature") 133 | item { 134 | bind: service.path("/AirTemperature") 135 | displayUnit: user.temperatureUnit 136 | } 137 | show: item.valid 138 | } 139 | 140 | MbItemValue { 141 | description: qsTr("Starter voltage") 142 | item.bind: service.path("/Dc/1/Voltage") 143 | show: item.valid 144 | } 145 | 146 | MbItemValue { 147 | description: qsTr("Bus voltage") 148 | item.bind: service.path("/BusVoltage") 149 | show: item.valid 150 | } 151 | 152 | MbItemValue { 153 | description: qsTr("Top section voltage") 154 | item { 155 | value: midVoltage.valid && dcVoltage.valid ? dcVoltage.value - midVoltage.value : undefined 156 | unit: "V" 157 | decimals: 2 158 | } 159 | show: midVoltage.valid 160 | } 161 | 162 | MbItemValue { 163 | description: qsTr("Bottom section voltage") 164 | item: midVoltage 165 | show: item.valid 166 | } 167 | 168 | MbItemValue { 169 | description: qsTr("Mid-point deviation") 170 | item.bind: service.path("/Dc/0/MidVoltageDeviation") 171 | show: item.valid 172 | } 173 | 174 | MbItemValue { 175 | description: qsTr("Consumed AmpHours") 176 | item.bind: service.path("/ConsumedAmphours") 177 | show: item.valid 178 | } 179 | 180 | MbItemValue { 181 | description: qsTr("Bus voltage") 182 | item.bind: service.path("/BussVoltage") 183 | show: item.valid 184 | } 185 | 186 | /* Time to go also needs to display infinite value */ 187 | MbItemTimeSpan { 188 | description: qsTr("Time-to-go") 189 | item.bind: service.path("/TimeToGo") 190 | show: item.seen 191 | } 192 | 193 | MbItemValue { 194 | description: qsTr("Time-to-SoC 0%") 195 | item.bind: service.path("/TimeToSoC/0") 196 | show: item.seen 197 | } 198 | 199 | MbItemValue { 200 | description: qsTr("Time-to-SoC 10%") 201 | item.bind: service.path("/TimeToSoC/10") 202 | show: item.seen 203 | } 204 | 205 | MbItemValue { 206 | description: qsTr("Time-to-SoC 20%") 207 | item.bind: service.path("/TimeToSoC/20") 208 | show: item.seen 209 | } 210 | 211 | MbItemValue { 212 | description: qsTr("Time-to-SoC 80%") 213 | item.bind: service.path("/TimeToSoC/80") 214 | show: item.seen 215 | } 216 | 217 | MbItemValue { 218 | description: qsTr("Time-to-SoC 90%") 219 | item.bind: service.path("/TimeToSoC/90") 220 | show: item.seen 221 | } 222 | 223 | MbItemValue { 224 | description: qsTr("Time-to-SoC 100%") 225 | item.bind: service.path("/TimeToSoC/100") 226 | show: item.seen 227 | } 228 | 229 | MbItemOptions { 230 | description: qsTr("Relay state") 231 | bind: service.path("/Relay/0/State") 232 | readonly: true 233 | possibleValues:[ 234 | MbOption { description: qsTr("Off"); value: 0 }, 235 | MbOption { description: qsTr("On"); value: 1 } 236 | ] 237 | show: valid 238 | } 239 | 240 | MbItemOptions { 241 | description: qsTr("Alarm state") 242 | bind: service.path("/Alarms/Alarm") 243 | readonly: true 244 | possibleValues:[ 245 | MbOption { description: qsTr("Ok"); value: 0 }, 246 | MbOption { description: qsTr("Alarm"); value: 1 } 247 | ] 248 | show: valid 249 | } 250 | 251 | MbSubMenu { 252 | description: qsTr("Details") 253 | show: details.anyItemValid 254 | 255 | property BatteryDetails details: BatteryDetails { id: details; bindPrefix: service.path("") } 256 | 257 | subpage: Component { 258 | PageBatteryDetails { 259 | bindPrefix: service.path("") 260 | details: details 261 | } 262 | } 263 | } 264 | 265 | MbSubMenu { 266 | description: qsTr("Cell Voltages") 267 | show: cell1.valid 268 | subpage: Component { 269 | PageBatteryCellVoltages { 270 | bindPrefix: service.path("") 271 | } 272 | } 273 | } 274 | 275 | /*MbSubMenu { 276 | description: qsTr("Setup") 277 | subpage: Component { 278 | PageBatterySetup { 279 | bindPrefix: service.path("") 280 | } 281 | } 282 | }*/ 283 | 284 | MbSubMenu { 285 | description: qsTr("Alarms") 286 | subpage: Component { 287 | PageBatteryAlarms { 288 | title: qsTr("Alarms") 289 | bindPrefix: service.path("") 290 | } 291 | } 292 | } 293 | 294 | MbSubMenu { 295 | description: qsTr("History") 296 | subpage: Component { 297 | PageBatteryHistory { 298 | title: qsTr("History") 299 | bindPrefix: service.path("") 300 | } 301 | } 302 | show: !isFiamm48TL 303 | } 304 | 305 | MbSubMenu { 306 | id: settings 307 | description: qsTr("Settings") 308 | show: hasSettings.value === 1 309 | subpage: Component { 310 | PageBatterySettings { 311 | title: settings.description 312 | bindPrefix: service.path("") 313 | } 314 | } 315 | } 316 | 317 | MbSubMenu { 318 | property VBusItem lastError: VBusItem { bind: service.path("/Diagnostics/LastErrors/1/Error") } 319 | 320 | description: qsTr("Diagnostics") 321 | subpage: Component { 322 | PageLynxIonDiagnostics { 323 | title: qsTr("Diagnostics") 324 | bindPrefix: service.path("") 325 | } 326 | } 327 | show: lastError.valid 328 | } 329 | 330 | MbSubMenu { 331 | description: qsTr("Diagnostics") 332 | subpage: Component { 333 | Page48TlDiagnostics { 334 | title: qsTr("Diagnostics") 335 | bindPrefix: service.path("") 336 | } 337 | } 338 | show: isFiamm48TL 339 | } 340 | 341 | MbSubMenu { 342 | description: qsTr("Fuses") 343 | subpage: distributorListPage 344 | show: numberOfDistributors > 0 345 | } 346 | 347 | MbSubMenu { 348 | property VBusItem allowToCharge: VBusItem { bind: service.path("/Io/AllowToCharge") } 349 | property VBusItem allowToBalance: VBusItem { bind: service.path("/Io/AllowToBalance") } 350 | 351 | description: qsTr("IO") 352 | subpage: Component { 353 | PageLynxIonIo { 354 | title: qsTr("IO") 355 | bindPrefix: service.path("") 356 | } 357 | } 358 | show: allowToCharge.valid || allowToBalance.valid 359 | } 360 | 361 | MbSubMenu { 362 | property VBusItem nrOfBatteries: VBusItem { bind: service.path("/System/NrOfBatteries") } 363 | 364 | description: qsTr("System") 365 | subpage: Component { 366 | PageLynxIonSystem { 367 | title: qsTr("System") 368 | bindPrefix: service.path("") 369 | } 370 | } 371 | show: nrOfBatteries.valid 372 | } 373 | 374 | MbSubMenu { 375 | description: qsTr("Device") 376 | subpage: Component { 377 | PageDeviceInfo { 378 | title: qsTr("Device") 379 | bindPrefix: service.path("") 380 | } 381 | } 382 | } 383 | 384 | MbSubMenu { 385 | property VBusItem cvl: VBusItem { bind: service.path("/Info/MaxChargeVoltage") } 386 | property VBusItem ccl: VBusItem { bind: service.path("/Info/MaxChargeCurrent") } 387 | property VBusItem dcl: VBusItem { bind: service.path("/Info/MaxDischargeCurrent") } 388 | 389 | description: qsTr("Parameters") 390 | show: cvl.valid || ccl.valid || dcl.valid 391 | subpage: Component { 392 | PageBatteryParameters { 393 | title: qsTr("Parameters") 394 | service: root.service 395 | } 396 | } 397 | } 398 | 399 | MbOK { 400 | VBusItem { 401 | id: redetect 402 | bind: service.path("/Redetect") 403 | } 404 | 405 | description: qsTr("Redetect Battery") 406 | value: qsTr("Press to redetect") 407 | editable: redetect.value === 0 408 | show: redetect.valid 409 | cornerMark: false 410 | writeAccessLevel: User.AccessUser 411 | onClicked: { 412 | redetect.setValue(1) 413 | toast.createToast(qsTr("Redetecting the battery may take up time 60 seconds. Meanwhile the name of the battery may be incorrect."), 10000); 414 | } 415 | } 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /dbushelper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | import platform 5 | import dbus 6 | import traceback 7 | 8 | # Victron packages 9 | sys.path.insert( 10 | 1, 11 | os.path.join( 12 | os.path.dirname(__file__), 13 | "/opt/victronenergy/dbus-systemcalc-py/ext/velib_python", 14 | ), 15 | ) 16 | from vedbus import VeDbusService 17 | from settingsdevice import SettingsDevice 18 | import battery 19 | from utils import * 20 | 21 | 22 | def get_bus(): 23 | return ( 24 | dbus.SessionBus() 25 | if "DBUS_SESSION_BUS_ADDRESS" in os.environ 26 | else dbus.SystemBus() 27 | ) 28 | 29 | 30 | class DbusHelper: 31 | def __init__(self, battery): 32 | self.battery = battery 33 | self.instance = 1 34 | self.settings = None 35 | self.error_count = 0 36 | self._dbusservice = VeDbusService( 37 | "com.victronenergy.battery." 38 | + self.battery.port[self.battery.port.rfind("/") + 1 :], 39 | get_bus(), 40 | ) 41 | 42 | def setup_instance(self): 43 | # bms_id = self.battery.production if self.battery.production is not None else \ 44 | # self.battery.port[self.battery.port.rfind('/') + 1:] 45 | bms_id = self.battery.port[self.battery.port.rfind("/") + 1 :] 46 | path = "/Settings/Devices/bluetoothbattery" 47 | default_instance = "battery:1" 48 | settings = { 49 | "instance": [ 50 | path + "_" + str(bms_id).replace(" ", "_") + "/ClassAndVrmInstance", 51 | default_instance, 52 | 0, 53 | 0, 54 | ], 55 | # 'CellVoltageMin': [path + '/CellVoltageMin', 2.8, 0.0, 5.0], 56 | # 'CellVoltageMax': [path + '/CellVoltageMax', 3.45, 0.0, 5.0], 57 | # 'CellVoltageFloat': [path + '/CellVoltageFloat', 3.35, 0.0, 5.0], 58 | # 'VoltageMaxTime': [path + '/VoltageMaxTime', 900, 0, 0], 59 | # 'VoltageResetSocLimit': [path + '/VoltageResetSocLimit', 90, 0, 100], 60 | # 'MaxChargeCurrent': [path + '/MaxCurrentCharge', 5, 0.0, 500], 61 | # 'MaxDischargeCurrent': [path + '/MaxCurrentDischarge', 7, 0.0, 500], 62 | # 'AllowDynamicChargeCurrent': [path + '/AllowDynamicChargeCurrent', 1, 0, 1], 63 | # 'AllowDynamicDischargeCurrent': [path + '/AllowDynamicDischargeCurrent', 1, 0, 1], 64 | # 'AllowDynamicChargeVoltage': [path + '/AllowDynamicChargeVoltage', 0, 0, 1], 65 | # 'SocLowWarning': [path + '/SocLowWarning', 20, 0, 100], 66 | # 'SocLowAlarm': [path + '/SocLowAlarm', 10, 0, 100], 67 | # 'Capacity': [path + '/Capacity', '', 0, 500], 68 | # 'EnableInvertedCurrent': [path + '/EnableInvertedCurrent', 0, 0, 1], 69 | # 'CCMSocLimitCharge1': [path + '/CCMSocLimitCharge1', 98, 0, 100], 70 | # 'CCMSocLimitCharge2': [path + '/CCMSocLimitCharge2', 95, 0, 100], 71 | # 'CCMSocLimitCharge3': [path + '/CCMSocLimitCharge3', 91, 0, 100], 72 | # 'CCMSocLimitDischarge1': [path + '/CCMSocLimitDischarge1', 10, 0, 100], 73 | # 'CCMSocLimitDischarge2': [path + '/CCMSocLimitDischarge2', 20, 0, 100], 74 | # 'CCMSocLimitDischarge3': [path + '/CCMSocLimitDischarge3', 30, 0, 100], 75 | # 'CCMCurrentLimitCharge1': [path + '/CCMCurrentLimitCharge1', 5, 0, 100], 76 | # 'CCMCurrentLimitCharge2': [path + '/CCMCurrentLimitCharge2', '', 0, 100], 77 | # 'CCMCurrentLimitCharge3': [path + '/CCMCurrentLimitCharge3', '', 0, 100], 78 | # 'CCMCurrentLimitDischarge1': [path + '/CCMCurrentLimitDischarge1', 5, 0, 100], 79 | # 'CCMCurrentLimitDischarge2': [path + '/CCMCurrentLimitDischarge2', '', 0, 100], 80 | # 'CCMCurrentLimitDischarge3': [path + '/CCMCurrentLimitDischarge3', '', 0, 100], 81 | } 82 | 83 | self.settings = SettingsDevice(get_bus(), settings, self.handle_changed_setting) 84 | self.battery.role, self.instance = self.get_role_instance() 85 | 86 | def get_role_instance(self): 87 | val = self.settings["instance"].split(":") 88 | logger.info("DeviceInstance = %d", int(val[1])) 89 | return val[0], int(val[1]) 90 | 91 | def handle_changed_setting(self, setting, oldvalue, newvalue): 92 | if setting == "instance": 93 | self.battery.role, self.instance = self.get_role_instance() 94 | logger.info("Changed DeviceInstance = %d", self.instance) 95 | return 96 | logger.info( 97 | "Changed DeviceInstance = %d", float(self.settings["CellVoltageMin"]) 98 | ) 99 | # self._dbusservice['/History/ChargeCycles'] 100 | 101 | def setup_vedbus(self): 102 | # Set up dbus service and device instance 103 | # and notify of all the attributes we intend to update 104 | # This is only called once when a battery is initiated 105 | self.setup_instance() 106 | short_port = self.battery.port[self.battery.port.rfind("/") + 1 :] 107 | logger.info("%s" % ("com.victronenergy.battery." + short_port)) 108 | 109 | # Get the settings for the battery 110 | if not self.battery.get_settings(): 111 | return False 112 | 113 | # Create the management objects, as specified in the ccgx dbus-api document 114 | self._dbusservice.add_path("/Mgmt/ProcessName", __file__) 115 | self._dbusservice.add_path( 116 | "/Mgmt/ProcessVersion", "Python " + platform.python_version() 117 | ) 118 | self._dbusservice.add_path("/Mgmt/Connection", "Bluetooth " + self.battery.port) 119 | 120 | # Create the mandatory objects 121 | self._dbusservice.add_path("/DeviceInstance", self.instance) 122 | self._dbusservice.add_path("/ProductId", 0x0) 123 | self._dbusservice.add_path( 124 | "/ProductName", "BluetoothBattery(" + self.battery.type + ")" 125 | ) 126 | self._dbusservice.add_path( 127 | "/FirmwareVersion", str(DRIVER_VERSION) + DRIVER_SUBVERSION 128 | ) 129 | self._dbusservice.add_path("/HardwareVersion", self.battery.hardware_version) 130 | self._dbusservice.add_path("/Connected", 1) 131 | self._dbusservice.add_path( 132 | "/CustomName", "BluetoothBattery(" + self.battery.type + ")", writeable=True 133 | ) 134 | 135 | # Create static battery info 136 | self._dbusservice.add_path( 137 | "/Info/BatteryLowVoltage", self.battery.min_battery_voltage, writeable=True 138 | ) 139 | self._dbusservice.add_path( 140 | "/Info/MaxChargeVoltage", 141 | self.battery.max_battery_voltage, 142 | writeable=True, 143 | gettextcallback=lambda p, v: "{:0.2f}V".format(v), 144 | ) 145 | self._dbusservice.add_path( 146 | "/Info/MaxChargeCurrent", 147 | self.battery.max_battery_charge_current, 148 | writeable=True, 149 | gettextcallback=lambda p, v: "{:0.2f}A".format(v), 150 | ) 151 | self._dbusservice.add_path( 152 | "/Info/MaxDischargeCurrent", 153 | self.battery.max_battery_discharge_current, 154 | writeable=True, 155 | gettextcallback=lambda p, v: "{:0.2f}A".format(v), 156 | ) 157 | self._dbusservice.add_path( 158 | "/System/NrOfCellsPerBattery", self.battery.cell_count, writeable=True 159 | ) 160 | self._dbusservice.add_path("/System/NrOfModulesOnline", 1, writeable=True) 161 | self._dbusservice.add_path("/System/NrOfModulesOffline", 0, writeable=True) 162 | self._dbusservice.add_path( 163 | "/System/NrOfModulesBlockingCharge", None, writeable=True 164 | ) 165 | self._dbusservice.add_path( 166 | "/System/NrOfModulesBlockingDischarge", None, writeable=True 167 | ) 168 | self._dbusservice.add_path( 169 | "/Capacity", 170 | self.battery.get_capacity_remain(), 171 | writeable=True, 172 | gettextcallback=lambda p, v: "{:0.2f}Ah".format(v), 173 | ) 174 | self._dbusservice.add_path( 175 | "/InstalledCapacity", 176 | self.battery.capacity, 177 | writeable=True, 178 | gettextcallback=lambda p, v: "{:0.0f}Ah".format(v), 179 | ) 180 | self._dbusservice.add_path( 181 | "/ConsumedAmphours", 182 | None, 183 | writeable=True, 184 | gettextcallback=lambda p, v: "{:0.0f}Ah".format(v), 185 | ) 186 | # Not used at this stage 187 | # self._dbusservice.add_path('/System/MinTemperatureCellId', None, writeable=True) 188 | # self._dbusservice.add_path('/System/MaxTemperatureCellId', None, writeable=True) 189 | 190 | # Create SOC, DC and System items 191 | self._dbusservice.add_path("/Soc", None, writeable=True) 192 | self._dbusservice.add_path( 193 | "/Dc/0/Voltage", 194 | None, 195 | writeable=True, 196 | gettextcallback=lambda p, v: "{:2.2f}V".format(v), 197 | ) 198 | self._dbusservice.add_path( 199 | "/Dc/0/Current", 200 | None, 201 | writeable=True, 202 | gettextcallback=lambda p, v: "{:2.2f}A".format(v), 203 | ) 204 | self._dbusservice.add_path( 205 | "/Dc/0/Power", 206 | None, 207 | writeable=True, 208 | gettextcallback=lambda p, v: "{:0.0f}W".format(v), 209 | ) 210 | self._dbusservice.add_path("/Dc/0/Temperature", None, writeable=True) 211 | self._dbusservice.add_path( 212 | "/Dc/0/MidVoltage", 213 | None, 214 | writeable=True, 215 | gettextcallback=lambda p, v: "{:0.2f}V".format(v), 216 | ) 217 | self._dbusservice.add_path( 218 | "/Dc/0/MidVoltageDeviation", 219 | None, 220 | writeable=True, 221 | gettextcallback=lambda p, v: "{:0.1f}%".format(v), 222 | ) 223 | 224 | # Create battery extras 225 | self._dbusservice.add_path("/System/MinCellTemperature", None, writeable=True) 226 | self._dbusservice.add_path("/System/MaxCellTemperature", None, writeable=True) 227 | self._dbusservice.add_path( 228 | "/System/MaxCellVoltage", 229 | None, 230 | writeable=True, 231 | gettextcallback=lambda p, v: "{:0.3f}V".format(v), 232 | ) 233 | self._dbusservice.add_path("/System/MaxVoltageCellId", None, writeable=True) 234 | self._dbusservice.add_path( 235 | "/System/MinCellVoltage", 236 | None, 237 | writeable=True, 238 | gettextcallback=lambda p, v: "{:0.3f}V".format(v), 239 | ) 240 | self._dbusservice.add_path("/System/MinVoltageCellId", None, writeable=True) 241 | self._dbusservice.add_path("/History/ChargeCycles", None, writeable=True) 242 | self._dbusservice.add_path("/History/TotalAhDrawn", None, writeable=True) 243 | self._dbusservice.add_path("/Balancing", None, writeable=True) 244 | self._dbusservice.add_path("/Io/AllowToCharge", 0, writeable=True) 245 | self._dbusservice.add_path("/Io/AllowToDischarge", 0, writeable=True) 246 | # self._dbusservice.add_path('/SystemSwitch',1,writeable=True) 247 | 248 | # Create the alarms 249 | self._dbusservice.add_path("/Alarms/LowVoltage", None, writeable=True) 250 | self._dbusservice.add_path("/Alarms/HighVoltage", None, writeable=True) 251 | self._dbusservice.add_path("/Alarms/LowCellVoltage", None, writeable=True) 252 | self._dbusservice.add_path("/Alarms/HighCellVoltage", None, writeable=True) 253 | self._dbusservice.add_path("/Alarms/LowSoc", None, writeable=True) 254 | self._dbusservice.add_path("/Alarms/HighChargeCurrent", None, writeable=True) 255 | self._dbusservice.add_path("/Alarms/HighDischargeCurrent", None, writeable=True) 256 | self._dbusservice.add_path("/Alarms/CellImbalance", None, writeable=True) 257 | self._dbusservice.add_path("/Alarms/InternalFailure", None, writeable=True) 258 | self._dbusservice.add_path( 259 | "/Alarms/HighChargeTemperature", None, writeable=True 260 | ) 261 | self._dbusservice.add_path("/Alarms/LowChargeTemperature", None, writeable=True) 262 | self._dbusservice.add_path("/Alarms/HighTemperature", None, writeable=True) 263 | self._dbusservice.add_path("/Alarms/LowTemperature", None, writeable=True) 264 | 265 | # cell voltages 266 | if BATTERY_CELL_DATA_FORMAT > 0: 267 | for i in range(1, self.battery.cell_count + 1): 268 | cellpath = ( 269 | "/Cell/%s/Volts" 270 | if (BATTERY_CELL_DATA_FORMAT & 2) 271 | else "/Voltages/Cell%s" 272 | ) 273 | self._dbusservice.add_path( 274 | cellpath % (str(i)), 275 | None, 276 | writeable=True, 277 | gettextcallback=lambda p, v: "{:0.3f}V".format(v), 278 | ) 279 | if BATTERY_CELL_DATA_FORMAT & 1: 280 | self._dbusservice.add_path( 281 | "/Balances/Cell%s" % (str(i)), None, writeable=True 282 | ) 283 | pathbase = "Cell" if (BATTERY_CELL_DATA_FORMAT & 2) else "Voltages" 284 | self._dbusservice.add_path( 285 | "/%s/Sum" % pathbase, 286 | None, 287 | writeable=True, 288 | gettextcallback=lambda p, v: "{:2.2f}V".format(v), 289 | ) 290 | self._dbusservice.add_path( 291 | "/%s/Diff" % pathbase, 292 | None, 293 | writeable=True, 294 | gettextcallback=lambda p, v: "{:0.3f}V".format(v), 295 | ) 296 | 297 | # Create TimeToSoC items 298 | for num in TIME_TO_SOC_POINTS: 299 | self._dbusservice.add_path("/TimeToSoC/" + str(num), None, writeable=True) 300 | #Create TimeToGO item 301 | self._dbusservice.add_path("/TimeToGo", None, writeable=True) 302 | 303 | logger.info(f"publish config values = {PUBLISH_CONFIG_VALUES}") 304 | if PUBLISH_CONFIG_VALUES == 1: 305 | publish_config_variables(self._dbusservice) 306 | 307 | return True 308 | 309 | def publish_battery(self, loop): 310 | # This is called every battery.poll_interval milli second as set up per battery type to read and update the data 311 | try: 312 | # Call the battery's refresh_data function 313 | success = self.battery.refresh_data() 314 | if success: 315 | self.error_count = 0 316 | self.battery.online = True 317 | else: 318 | self.error_count += 1 319 | # If the battery is offline for more than 10 polls (polled every second for most batteries) 320 | if self.error_count >= 10: 321 | self.battery.online = False 322 | # Has it completely failed 323 | if self.error_count >= 60: 324 | loop.quit() 325 | 326 | # This is to mannage CCL\DCL 327 | self.battery.manage_charge_current() 328 | 329 | # This is to mannage CVCL 330 | self.battery.manage_charge_voltage() 331 | 332 | # publish all the data from the battery object to dbus 333 | self.publish_dbus() 334 | 335 | except: 336 | traceback.print_exc() 337 | loop.quit() 338 | 339 | def publish_dbus(self): 340 | 341 | # Update SOC, DC and System items 342 | self._dbusservice["/System/NrOfCellsPerBattery"] = self.battery.cell_count 343 | self._dbusservice["/Soc"] = round(self.battery.soc, 2) 344 | self._dbusservice["/Dc/0/Voltage"] = round(self.battery.voltage, 2) 345 | self._dbusservice["/Dc/0/Current"] = round(self.battery.current, 2) 346 | self._dbusservice["/Dc/0/Power"] = round( 347 | self.battery.voltage * self.battery.current, 2 348 | ) 349 | self._dbusservice["/Dc/0/Temperature"] = self.battery.get_temp() 350 | self._dbusservice["/Capacity"] = self.battery.get_capacity_remain() 351 | self._dbusservice["/ConsumedAmphours"] = ( 352 | 0 353 | if self.battery.capacity is None 354 | or self.battery.get_capacity_remain() is None 355 | else self.battery.capacity - self.battery.get_capacity_remain() 356 | ) 357 | 358 | midpoint, deviation = self.battery.get_midvoltage() 359 | if midpoint is not None: 360 | self._dbusservice["/Dc/0/MidVoltage"] = midpoint 361 | self._dbusservice["/Dc/0/MidVoltageDeviation"] = deviation 362 | 363 | # Update battery extras 364 | self._dbusservice["/History/ChargeCycles"] = self.battery.cycles 365 | self._dbusservice["/History/TotalAhDrawn"] = self.battery.total_ah_drawn 366 | self._dbusservice["/Io/AllowToCharge"] = ( 367 | 1 if self.battery.charge_fet and self.battery.control_allow_charge else 0 368 | ) 369 | self._dbusservice["/Io/AllowToDischarge"] = ( 370 | 1 371 | if self.battery.discharge_fet and self.battery.control_allow_discharge 372 | else 0 373 | ) 374 | self._dbusservice["/System/NrOfModulesBlockingCharge"] = ( 375 | 0 376 | if self.battery.charge_fet is None 377 | or (self.battery.charge_fet and self.battery.control_allow_charge) 378 | else 1 379 | ) 380 | self._dbusservice["/System/NrOfModulesBlockingDischarge"] = ( 381 | 0 if self.battery.discharge_fet is None or self.battery.discharge_fet else 1 382 | ) 383 | self._dbusservice["/System/NrOfModulesOnline"] = 1 if self.battery.online else 0 384 | self._dbusservice["/System/NrOfModulesOffline"] = ( 385 | 0 if self.battery.online else 1 386 | ) 387 | self._dbusservice["/System/MinCellTemperature"] = self.battery.get_min_temp() 388 | self._dbusservice["/System/MaxCellTemperature"] = self.battery.get_max_temp() 389 | 390 | # Charge control 391 | self._dbusservice[ 392 | "/Info/MaxChargeCurrent" 393 | ] = self.battery.control_charge_current 394 | self._dbusservice[ 395 | "/Info/MaxDischargeCurrent" 396 | ] = self.battery.control_discharge_current 397 | 398 | # Voltage control 399 | self._dbusservice["/Info/MaxChargeVoltage"] = self.battery.control_voltage 400 | 401 | # Updates from cells 402 | self._dbusservice["/System/MinVoltageCellId"] = self.battery.get_min_cell_desc() 403 | self._dbusservice["/System/MaxVoltageCellId"] = self.battery.get_max_cell_desc() 404 | self._dbusservice[ 405 | "/System/MinCellVoltage" 406 | ] = self.battery.get_min_cell_voltage() 407 | self._dbusservice[ 408 | "/System/MaxCellVoltage" 409 | ] = self.battery.get_max_cell_voltage() 410 | self._dbusservice["/Balancing"] = self.battery.get_balancing() 411 | 412 | # Update the alarms 413 | self._dbusservice["/Alarms/LowVoltage"] = self.battery.protection.voltage_low 414 | self._dbusservice[ 415 | "/Alarms/LowCellVoltage" 416 | ] = self.battery.protection.voltage_cell_low 417 | self._dbusservice["/Alarms/HighVoltage"] = self.battery.protection.voltage_high 418 | self._dbusservice["/Alarms/LowSoc"] = self.battery.protection.soc_low 419 | self._dbusservice[ 420 | "/Alarms/HighChargeCurrent" 421 | ] = self.battery.protection.current_over 422 | self._dbusservice[ 423 | "/Alarms/HighDischargeCurrent" 424 | ] = self.battery.protection.current_under 425 | self._dbusservice[ 426 | "/Alarms/CellImbalance" 427 | ] = self.battery.protection.cell_imbalance 428 | self._dbusservice[ 429 | "/Alarms/InternalFailure" 430 | ] = self.battery.protection.internal_failure 431 | self._dbusservice[ 432 | "/Alarms/HighChargeTemperature" 433 | ] = self.battery.protection.temp_high_charge 434 | self._dbusservice[ 435 | "/Alarms/LowChargeTemperature" 436 | ] = self.battery.protection.temp_low_charge 437 | self._dbusservice[ 438 | "/Alarms/HighTemperature" 439 | ] = self.battery.protection.temp_high_discharge 440 | self._dbusservice[ 441 | "/Alarms/LowTemperature" 442 | ] = self.battery.protection.temp_low_discharge 443 | 444 | # cell voltages 445 | if BATTERY_CELL_DATA_FORMAT > 0: 446 | try: 447 | voltageSum = 0 448 | for i in range(self.battery.cell_count): 449 | voltage = self.battery.get_cell_voltage(i) 450 | cellpath = ( 451 | "/Cell/%s/Volts" 452 | if (BATTERY_CELL_DATA_FORMAT & 2) 453 | else "/Voltages/Cell%s" 454 | ) 455 | self._dbusservice[cellpath % (str(i + 1))] = voltage 456 | if BATTERY_CELL_DATA_FORMAT & 1: 457 | self._dbusservice[ 458 | "/Balances/Cell%s" % (str(i + 1)) 459 | ] = self.battery.get_cell_balancing(i) 460 | if voltage: 461 | voltageSum += voltage 462 | pathbase = "Cell" if (BATTERY_CELL_DATA_FORMAT & 2) else "Voltages" 463 | self._dbusservice["/%s/Sum" % pathbase] = voltageSum 464 | self._dbusservice["/%s/Diff" % pathbase] = ( 465 | self.battery.get_max_cell_voltage() 466 | - self.battery.get_min_cell_voltage() 467 | ) 468 | except: 469 | pass 470 | 471 | # Update TimeToSoC 472 | try: 473 | if ( 474 | self.battery.capacity is not None 475 | and len(TIME_TO_SOC_POINTS) > 0 476 | and self.battery.time_to_soc_update == 0 477 | ): 478 | self.battery.time_to_soc_update = TIME_TO_SOC_LOOP_CYCLES 479 | crntPrctPerSec = ( 480 | abs(self.battery.current / (self.battery.capacity / 100)) / 3600 481 | ) 482 | 483 | for num in TIME_TO_SOC_POINTS: 484 | self._dbusservice["/TimeToSoC/" + str(num)] = ( 485 | self.battery.get_timetosoc(num, crntPrctPerSec) 486 | if self.battery.current 487 | else None 488 | ) 489 | 490 | # Update TimeToGo 491 | self._dbusservice["/TimeToGo"] = ( 492 | self.battery.get_timetosoc(SOC_LOW_WARNING, crntPrctPerSec) 493 | if self.battery.current 494 | else None 495 | ) 496 | 497 | else: 498 | self.battery.time_to_soc_update -= 1 499 | except: 500 | pass 501 | 502 | logger.debug("logged to dbus [%s]" % str(round(self.battery.soc, 2))) 503 | self.battery.log_cell_data() 504 | -------------------------------------------------------------------------------- /battery.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Union, Tuple, List 3 | 4 | from utils import logger 5 | import utils 6 | import logging 7 | import math 8 | from datetime import timedelta 9 | from time import time 10 | from abc import ABC, abstractmethod 11 | 12 | 13 | class Protection(object): 14 | """ 15 | This class holds Warning and alarm states for different types of Checks 16 | They are of type integer, 2 represents an Alarm, 1 a Warning, 0 if everything is fine 17 | """ 18 | 19 | def __init__(self): 20 | self.voltage_high: int = None 21 | self.voltage_low: int = None 22 | self.voltage_cell_low: int = None 23 | self.soc_low: int = None 24 | self.current_over: int = None 25 | self.current_under: int = None 26 | self.cell_imbalance: int = None 27 | self.internal_failure: int = None 28 | self.temp_high_charge: int = None 29 | self.temp_low_charge: int = None 30 | self.temp_high_discharge: int = None 31 | self.temp_low_discharge: int = None 32 | 33 | 34 | class Cell: 35 | """ 36 | This class holds information about a single Cell 37 | """ 38 | 39 | voltage = None 40 | balance = None 41 | temp = None 42 | 43 | def __init__(self, balance): 44 | self.balance = balance 45 | 46 | 47 | class Battery(ABC): 48 | """ 49 | This Class is the abstract baseclass for all batteries. For each BMS this class needs to be extended 50 | and the abstract methods need to be implemented. The main program in dbus-btbattery.py will then 51 | use the individual implementations as type Battery and work with it. 52 | """ 53 | 54 | def __init__(self, port, baud, address): 55 | self.port = port 56 | self.baud_rate = baud 57 | self.role = "battery" 58 | self.type = "Generic" 59 | self.poll_interval = 1000 60 | self.online = True 61 | 62 | self.hardware_version = None 63 | self.voltage = None 64 | self.current = None 65 | self.capacity_remain = None 66 | self.capacity = None 67 | self.cycles = None 68 | self.total_ah_drawn = None 69 | self.production = None 70 | self.protection = Protection() 71 | self.version = None 72 | self.soc = None 73 | self.charge_fet = None 74 | self.discharge_fet = None 75 | self.cell_count = None 76 | self.temp_sensors = None 77 | self.temp1 = None 78 | self.temp2 = None 79 | self.cells: List[Cell] = [] 80 | self.control_charging = None 81 | self.control_voltage = None 82 | self.allow_max_voltage = True 83 | self.max_voltage_start_time = None 84 | self.control_current = None 85 | self.control_previous_total = None 86 | self.control_previous_max = None 87 | self.control_discharge_current = None 88 | self.control_charge_current = None 89 | self.control_allow_charge = None 90 | self.control_allow_discharge = None 91 | # max battery charge/discharge current 92 | self.max_battery_charge_current = None 93 | self.max_battery_discharge_current = None 94 | 95 | self.time_to_soc_update = utils.TIME_TO_SOC_LOOP_CYCLES 96 | 97 | @abstractmethod 98 | def test_connection(self) -> bool: 99 | """ 100 | This abstract method needs to be implemented for each BMS. It shoudl return true if a connection 101 | to the BMS can be established, false otherwise. 102 | :return: the success state 103 | """ 104 | # Each driver must override this function to test if a connection can be made 105 | # return false when failed, true if successful 106 | return False 107 | 108 | @abstractmethod 109 | def get_settings(self) -> bool: 110 | """ 111 | Each driver must override this function to read/set the battery settings 112 | It is called once after a successful connection by DbusHelper.setup_vedbus() 113 | Values: battery_type, version, hardware_version, min_battery_voltage, max_battery_voltage, 114 | MAX_BATTERY_CHARGE_CURRENT, MAX_BATTERY_DISCHARGE_CURRENT, cell_count, capacity 115 | 116 | :return: false when fail, true if successful 117 | """ 118 | return False 119 | 120 | @abstractmethod 121 | def refresh_data(self) -> bool: 122 | """ 123 | Each driver must override this function to read battery data and populate this class 124 | It is called each poll just before the data is published to vedbus 125 | 126 | :return: false when fail, true if successful 127 | """ 128 | return False 129 | 130 | def to_temp(self, sensor: int, value: float) -> None: 131 | """ 132 | Keep the temp value between -20 and 100 to handle sensor issues or no data. 133 | The BMS should have already protected before those limits have been reached. 134 | 135 | :param sensor: temperature sensor number 136 | :param value: the sensor value 137 | :return: 138 | """ 139 | if sensor == 1: 140 | self.temp1 = min(max(value, -20), 100) 141 | if sensor == 2: 142 | self.temp2 = min(max(value, -20), 100) 143 | 144 | def manage_charge_voltage(self) -> None: 145 | """ 146 | manages the charge voltage by setting self.control_voltage 147 | :return: None 148 | """ 149 | if utils.LINEAR_LIMITATION_ENABLE: 150 | self.manage_charge_voltage_linear() 151 | else: 152 | self.manage_charge_voltage_step() 153 | 154 | def manage_charge_voltage_linear(self) -> None: 155 | """ 156 | manages the charge voltage using linear interpolation by setting self.control_voltage 157 | :return: None 158 | """ 159 | foundHighCellVoltage = False 160 | if utils.CVCM_ENABLE: 161 | currentBatteryVoltage = 0 162 | penaltySum = 0 163 | for i in range(self.cell_count): 164 | cv = self.get_cell_voltage(i) 165 | if cv: 166 | currentBatteryVoltage += cv 167 | 168 | if cv >= utils.PENALTY_AT_CELL_VOLTAGE[0]: 169 | foundHighCellVoltage = True 170 | penaltySum += utils.calcLinearRelationship( 171 | cv, 172 | utils.PENALTY_AT_CELL_VOLTAGE, 173 | utils.PENALTY_BATTERY_VOLTAGE, 174 | ) 175 | self.voltage = currentBatteryVoltage # for testing 176 | 177 | if foundHighCellVoltage: 178 | # Keep penalty above min battery voltage 179 | self.control_voltage = max( 180 | currentBatteryVoltage - penaltySum, 181 | utils.MIN_CELL_VOLTAGE * self.cell_count, 182 | ) 183 | else: 184 | self.control_voltage = utils.FLOAT_CELL_VOLTAGE * self.cell_count 185 | 186 | def manage_charge_voltage_step(self) -> None: 187 | """ 188 | manages the charge voltage using a step function by setting self.control_voltage 189 | :return: None 190 | """ 191 | voltageSum = 0 192 | if utils.CVCM_ENABLE: 193 | for i in range(self.cell_count): 194 | voltage = self.get_cell_voltage(i) 195 | if voltage: 196 | voltageSum += voltage 197 | 198 | if self.max_voltage_start_time is None: 199 | if ( 200 | utils.MAX_CELL_VOLTAGE * self.cell_count <= voltageSum 201 | and self.allow_max_voltage 202 | ): 203 | self.max_voltage_start_time = time() 204 | else: 205 | if ( 206 | utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT > self.soc 207 | and not self.allow_max_voltage 208 | ): 209 | self.allow_max_voltage = True 210 | else: 211 | tDiff = time() - self.max_voltage_start_time 212 | if utils.MAX_VOLTAGE_TIME_SEC < tDiff: 213 | self.max_voltage_start_time = None 214 | self.allow_max_voltage = False 215 | 216 | if self.allow_max_voltage: 217 | # Keep penalty above min battery voltage 218 | self.control_voltage = max( 219 | utils.MAX_CELL_VOLTAGE * self.cell_count, 220 | utils.MIN_CELL_VOLTAGE * self.cell_count, 221 | ) 222 | else: 223 | self.control_voltage = utils.FLOAT_CELL_VOLTAGE * self.cell_count 224 | 225 | def manage_charge_current(self) -> None: 226 | # Manage Charge Current Limitations 227 | charge_limits = [self.max_battery_charge_current] 228 | if utils.CCCM_SOC_ENABLE: 229 | charge_limits.append(self.calcMaxChargeCurrentReferringToSoc()) 230 | if utils.CCCM_CV_ENABLE: 231 | charge_limits.append(self.calcMaxChargeCurrentReferringToCellVoltage()) 232 | if utils.CCCM_T_ENABLE: 233 | charge_limits.append(self.calcMaxChargeCurrentReferringToTemperature()) 234 | 235 | self.control_charge_current = min(charge_limits) 236 | 237 | if self.control_charge_current == 0: 238 | self.control_allow_charge = False 239 | else: 240 | self.control_allow_charge = True 241 | 242 | # Manage Discharge Current Limitations 243 | discharge_limits = [self.max_battery_discharge_current] 244 | if utils.DCCM_SOC_ENABLE: 245 | discharge_limits.append(self.calcMaxDischargeCurrentReferringToSoc()) 246 | if utils.DCCM_CV_ENABLE: 247 | discharge_limits.append( 248 | self.calcMaxDischargeCurrentReferringToCellVoltage() 249 | ) 250 | if utils.DCCM_T_ENABLE: 251 | discharge_limits.append( 252 | self.calcMaxDischargeCurrentReferringToTemperature() 253 | ) 254 | 255 | self.control_discharge_current = min(discharge_limits) 256 | 257 | if self.control_discharge_current == 0: 258 | self.control_allow_discharge = False 259 | else: 260 | self.control_allow_discharge = True 261 | 262 | def calcMaxChargeCurrentReferringToCellVoltage(self) -> float: 263 | try: 264 | if utils.LINEAR_LIMITATION_ENABLE: 265 | return utils.calcLinearRelationship( 266 | self.get_max_cell_voltage(), 267 | utils.CELL_VOLTAGES_WHILE_CHARGING, 268 | utils.MAX_CHARGE_CURRENT_CV, 269 | ) 270 | return utils.calcStepRelationship( 271 | self.get_max_cell_voltage(), 272 | utils.CELL_VOLTAGES_WHILE_CHARGING, 273 | utils.MAX_CHARGE_CURRENT_CV, 274 | False, 275 | ) 276 | except Exception: 277 | return self.max_battery_charge_current 278 | 279 | def calcMaxDischargeCurrentReferringToCellVoltage(self) -> float: 280 | try: 281 | if utils.LINEAR_LIMITATION_ENABLE: 282 | return utils.calcLinearRelationship( 283 | self.get_min_cell_voltage(), 284 | utils.CELL_VOLTAGES_WHILE_DISCHARGING, 285 | utils.MAX_DISCHARGE_CURRENT_CV, 286 | ) 287 | return utils.calcStepRelationship( 288 | self.get_min_cell_voltage(), 289 | utils.CELL_VOLTAGES_WHILE_DISCHARGING, 290 | utils.MAX_DISCHARGE_CURRENT_CV, 291 | True, 292 | ) 293 | except Exception: 294 | return self.max_battery_charge_current 295 | 296 | def calcMaxChargeCurrentReferringToTemperature(self) -> float: 297 | if self.get_max_temp() is None: 298 | return self.max_battery_charge_current 299 | 300 | temps = {0: self.get_max_temp(), 1: self.get_min_temp()} 301 | 302 | for key, currentMaxTemperature in temps.items(): 303 | if utils.LINEAR_LIMITATION_ENABLE: 304 | temps[key] = utils.calcLinearRelationship( 305 | currentMaxTemperature, 306 | utils.TEMPERATURE_LIMITS_WHILE_CHARGING, 307 | utils.MAX_CHARGE_CURRENT_T, 308 | ) 309 | else: 310 | temps[key] = utils.calcStepRelationship( 311 | currentMaxTemperature, 312 | utils.TEMPERATURE_LIMITS_WHILE_CHARGING, 313 | utils.MAX_CHARGE_CURRENT_T, 314 | False, 315 | ) 316 | 317 | return min(temps[0], temps[1]) 318 | 319 | def calcMaxDischargeCurrentReferringToTemperature(self) -> float: 320 | if self.get_max_temp() is None: 321 | return self.max_battery_discharge_current 322 | 323 | temps = {0: self.get_max_temp(), 1: self.get_min_temp()} 324 | 325 | for key, currentMaxTemperature in temps.items(): 326 | if utils.LINEAR_LIMITATION_ENABLE: 327 | temps[key] = utils.calcLinearRelationship( 328 | currentMaxTemperature, 329 | utils.TEMPERATURE_LIMITS_WHILE_DISCHARGING, 330 | utils.MAX_DISCHARGE_CURRENT_T, 331 | ) 332 | else: 333 | temps[key] = utils.calcStepRelationship( 334 | currentMaxTemperature, 335 | utils.TEMPERATURE_LIMITS_WHILE_DISCHARGING, 336 | utils.MAX_DISCHARGE_CURRENT_T, 337 | True, 338 | ) 339 | 340 | return min(temps[0], temps[1]) 341 | 342 | def calcMaxChargeCurrentReferringToSoc(self) -> float: 343 | try: 344 | # Create value list. Will more this to the settings object 345 | SOC_WHILE_CHARGING = [ 346 | 100, 347 | utils.CC_SOC_LIMIT1, 348 | utils.CC_SOC_LIMIT2, 349 | utils.CC_SOC_LIMIT3, 350 | ] 351 | MAX_CHARGE_CURRENT_SOC = [ 352 | utils.CC_CURRENT_LIMIT1, 353 | utils.CC_CURRENT_LIMIT2, 354 | utils.CC_CURRENT_LIMIT3, 355 | utils.MAX_BATTERY_CHARGE_CURRENT, 356 | ] 357 | if utils.LINEAR_LIMITATION_ENABLE: 358 | return utils.calcLinearRelationship( 359 | self.soc, SOC_WHILE_CHARGING, MAX_CHARGE_CURRENT_SOC 360 | ) 361 | return utils.calcStepRelationship( 362 | self.soc, SOC_WHILE_CHARGING, MAX_CHARGE_CURRENT_SOC, True 363 | ) 364 | except Exception: 365 | return self.max_battery_charge_current 366 | 367 | def calcMaxDischargeCurrentReferringToSoc(self) -> float: 368 | try: 369 | # Create value list. Will more this to the settings object 370 | SOC_WHILE_DISCHARGING = [ 371 | utils.DC_SOC_LIMIT3, 372 | utils.DC_SOC_LIMIT2, 373 | utils.DC_SOC_LIMIT1, 374 | ] 375 | MAX_DISCHARGE_CURRENT_SOC = [ 376 | utils.MAX_BATTERY_DISCHARGE_CURRENT, 377 | utils.DC_CURRENT_LIMIT3, 378 | utils.DC_CURRENT_LIMIT2, 379 | utils.DC_CURRENT_LIMIT1, 380 | ] 381 | if utils.LINEAR_LIMITATION_ENABLE: 382 | return utils.calcLinearRelationship( 383 | self.soc, SOC_WHILE_DISCHARGING, MAX_DISCHARGE_CURRENT_SOC 384 | ) 385 | return utils.calcStepRelationship( 386 | self.soc, SOC_WHILE_DISCHARGING, MAX_DISCHARGE_CURRENT_SOC, True 387 | ) 388 | except Exception: 389 | return self.max_battery_charge_current 390 | 391 | def get_min_cell(self) -> int: 392 | min_voltage = 9999 393 | min_cell = None 394 | if len(self.cells) == 0 and hasattr(self, "cell_min_no"): 395 | return self.cell_min_no 396 | 397 | for c in range(min(len(self.cells), self.cell_count)): 398 | if ( 399 | self.cells[c].voltage is not None 400 | and min_voltage > self.cells[c].voltage 401 | ): 402 | min_voltage = self.cells[c].voltage 403 | min_cell = c 404 | return min_cell 405 | 406 | def get_max_cell(self) -> int: 407 | max_voltage = 0 408 | max_cell = None 409 | if len(self.cells) == 0 and hasattr(self, "cell_max_no"): 410 | return self.cell_max_no 411 | 412 | for c in range(min(len(self.cells), self.cell_count)): 413 | if ( 414 | self.cells[c].voltage is not None 415 | and max_voltage < self.cells[c].voltage 416 | ): 417 | max_voltage = self.cells[c].voltage 418 | max_cell = c 419 | return max_cell 420 | 421 | def get_min_cell_desc(self) -> Union[str, None]: 422 | cell_no = self.get_min_cell() 423 | return cell_no if cell_no is None else "C" + str(cell_no + 1) 424 | 425 | def get_max_cell_desc(self) -> Union[str, None]: 426 | cell_no = self.get_max_cell() 427 | return cell_no if cell_no is None else "C" + str(cell_no + 1) 428 | 429 | def get_cell_voltage(self, idx) -> Union[float, None]: 430 | if idx >= min(len(self.cells), self.cell_count): 431 | return None 432 | return self.cells[idx].voltage 433 | 434 | def get_cell_balancing(self, idx) -> Union[int, None]: 435 | if idx >= min(len(self.cells), self.cell_count): 436 | return None 437 | if self.cells[idx].balance is not None and self.cells[idx].balance: 438 | return 1 439 | return 0 440 | 441 | def get_capacity_remain(self) -> Union[float, None]: 442 | if self.capacity_remain is not None: 443 | return self.capacity_remain 444 | if self.capacity is not None and self.soc is not None: 445 | return self.capacity * self.soc / 100 446 | return None 447 | 448 | def get_timetosoc(self, socnum, crntPrctPerSec) -> str: 449 | if self.current > 0: 450 | diffSoc = socnum - self.soc 451 | else: 452 | diffSoc = self.soc - socnum 453 | 454 | ttgStr = None 455 | if self.soc != socnum and (diffSoc > 0 or utils.TIME_TO_SOC_INC_FROM is True): 456 | secondstogo = int(diffSoc / crntPrctPerSec) 457 | ttgStr = "" 458 | 459 | if utils.TIME_TO_SOC_VALUE_TYPE & 1: 460 | ttgStr += str(secondstogo) 461 | if utils.TIME_TO_SOC_VALUE_TYPE & 2: 462 | ttgStr += " [" 463 | if utils.TIME_TO_SOC_VALUE_TYPE & 2: 464 | ttgStr += str(timedelta(seconds=secondstogo)) 465 | if utils.TIME_TO_SOC_VALUE_TYPE & 1: 466 | ttgStr += "]" 467 | 468 | return ttgStr 469 | 470 | def get_min_cell_voltage(self) -> Union[float, None]: 471 | min_voltage = None 472 | if hasattr(self, "cell_min_voltage"): 473 | min_voltage = self.cell_min_voltage 474 | 475 | if min_voltage is None: 476 | try: 477 | min_voltage = min( 478 | c.voltage for c in self.cells if c.voltage is not None 479 | ) 480 | except ValueError: 481 | pass 482 | return min_voltage 483 | 484 | def get_max_cell_voltage(self) -> Union[float, None]: 485 | max_voltage = None 486 | if hasattr(self, "cell_max_voltage"): 487 | max_voltage = self.cell_max_voltage 488 | 489 | if max_voltage is None: 490 | try: 491 | max_voltage = max( 492 | c.voltage for c in self.cells if c.voltage is not None 493 | ) 494 | except ValueError: 495 | pass 496 | return max_voltage 497 | 498 | def get_midvoltage(self) -> Tuple[Union[float, None], Union[float, None]]: 499 | """ 500 | This method returns the Voltage "in the middle of the battery" 501 | as well as a deviation of an ideally balanced battery. It does so by calculating the sum of the first half 502 | of the cells and adding 1/2 of the "middle cell" voltage (if it exists) 503 | :return: a tuple of the voltage in the middle, as well as a percentage deviation (total_voltage / 2) 504 | """ 505 | if ( 506 | not utils.MIDPOINT_ENABLE 507 | or self.cell_count is None 508 | or self.cell_count == 0 509 | or self.cell_count < 4 510 | or len(self.cells) != self.cell_count 511 | ): 512 | return None, None 513 | 514 | halfcount = int(math.floor(self.cell_count / 2)) 515 | uneven_cells_offset = self.cell_count % 2 516 | half1voltage = 0 517 | half2voltage = 0 518 | 519 | try: 520 | half1voltage = sum( 521 | cell.voltage 522 | for cell in self.cells[:halfcount] 523 | if cell.voltage is not None 524 | ) 525 | half2voltage = sum( 526 | cell.voltage 527 | for cell in self.cells[halfcount + uneven_cells_offset :] 528 | if cell.voltage is not None 529 | ) 530 | except ValueError: 531 | pass 532 | 533 | try: 534 | extra = 0 if self.cell_count % 2 == 0 else self.cells[halfcount].voltage / 2 535 | # get the midpoint of the battery 536 | midpoint = half1voltage + extra 537 | return ( 538 | midpoint, 539 | (half2voltage - half1voltage) / (half2voltage + half1voltage) * 100, 540 | ) 541 | except ValueError: 542 | return None, None 543 | 544 | def get_balancing(self) -> int: 545 | for c in range(min(len(self.cells), self.cell_count)): 546 | if self.cells[c].balance is not None and self.cells[c].balance: 547 | return 1 548 | return 0 549 | 550 | def extract_from_temp_values(self, extractor) -> Union[float, None]: 551 | if self.temp1 is not None and self.temp2 is not None: 552 | return extractor(self.temp1, self.temp2) 553 | if self.temp1 is not None and self.temp2 is None: 554 | return self.temp1 555 | if self.temp1 is None and self.temp2 is not None: 556 | return self.temp2 557 | else: 558 | return None 559 | 560 | def get_temp(self) -> Union[float, None]: 561 | return self.extract_from_temp_values( 562 | extractor=lambda temp1, temp2: round((float(temp1) + float(temp2)) / 2, 2) 563 | ) 564 | 565 | def get_min_temp(self) -> Union[float, None]: 566 | return self.extract_from_temp_values( 567 | extractor=lambda temp1, temp2: min(temp1, temp2) 568 | ) 569 | 570 | def get_max_temp(self) -> Union[float, None]: 571 | return self.extract_from_temp_values( 572 | extractor=lambda temp1, temp2: max(temp1, temp2) 573 | ) 574 | 575 | def log_cell_data(self) -> bool: 576 | if logger.getEffectiveLevel() > logging.INFO and len(self.cells) == 0: 577 | return False 578 | 579 | cell_res = "" 580 | cell_counter = 1 581 | for c in self.cells: 582 | cell_res += "[{0}]{1}V ".format(cell_counter, c.voltage) 583 | cell_counter = cell_counter + 1 584 | logger.debug("Cells:" + cell_res) 585 | return True 586 | 587 | def log_settings(self) -> None: 588 | 589 | logger.info(f"Battery {self.type} connected to dbus from {self.port}") 590 | logger.info("=== Settings ===") 591 | cell_counter = len(self.cells) 592 | logger.info( 593 | f"> Connection voltage {self.voltage}V | current {self.current}A | SOC {self.soc}%" 594 | ) 595 | logger.info(f"> Cell count {self.cell_count} | cells populated {cell_counter}") 596 | logger.info( 597 | f"> CCCM SOC {utils.CCCM_SOC_ENABLE} | DCCM SOC {utils.DCCM_SOC_ENABLE}" 598 | ) 599 | logger.info( 600 | f"> CCCM CV {utils.CCCM_CV_ENABLE} | DCCM CV {utils.DCCM_CV_ENABLE}" 601 | ) 602 | logger.info(f"> CCCM T {utils.CCCM_T_ENABLE} | DCCM T {utils.DCCM_T_ENABLE}") 603 | logger.info( 604 | f"> MIN_CELL_VOLTAGE {utils.MIN_CELL_VOLTAGE}V | MAX_CELL_VOLTAGE {utils.MAX_CELL_VOLTAGE}V" 605 | ) 606 | 607 | return 608 | --------------------------------------------------------------------------------