├── 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 |
--------------------------------------------------------------------------------