add files

This commit is contained in:
mark 2025-10-14 20:47:40 -05:00
parent a7959663fb
commit 88970774a4
9 changed files with 960 additions and 1 deletions

184
JKBMS.py Normal file
View File

@ -0,0 +1,184 @@
#
# Copyright (C) 2025 Extrafu <extrafu@gmail.com>
#
# This file is part of BerryBMS.
#
# BerryBMS is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3, or (at your option) any
# later version.
#
import pymodbus.client as ModbusClient
from pymodbus.client.mixin import ModbusClientMixin
import pymodbus.exceptions
import json
from ModbusDevice import ModbusDevice
from Register import Register
class JKBMS(ModbusDevice):
def __init__(self,
name,
id,
port):
super().__init__(id)
self.name = name
self.port = port
self.registers = [
Register(self.id,'CellUnderVoltProtectionValue', 0x1004, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id,'CellOverVoltProtectionValue', 0x100C, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id,'100PercentSoCVoltageValue', 0x1018, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id,'ZeroPercentSoCVoltageValue', 0x101C, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id,'CellChargeOverCurrentProtection', 0x102C, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id,'CellDischargeOverCurrentProtection', 0x1038, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id, "CellCount", 0x106C, ModbusClientMixin.DATATYPE.UINT32),
Register(self.id, "BatChargeEN", 0x1070, ModbusClientMixin.DATATYPE.UINT32),
Register(self.id, "BatDisChargeEN", 0x1074, ModbusClientMixin.DATATYPE.UINT32),
Register(self.id, "BatBalanceEN", 0x1078, ModbusClientMixin.DATATYPE.UINT32),
Register(self.id, "StartBalanceVol", 0x1078, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id, "DeviceOptions", 0x1114, ModbusClientMixin.DATATYPE.UINT16, length=1),
Register(self.id, "CellVolAve", 0x1244, ModbusClientMixin.DATATYPE.UINT16, .001),
Register(self.id, "BatCurrent", 0x1298, ModbusClientMixin.DATATYPE.INT32, .001),
Register(self.id, "Alarms", 0x12A0, ModbusClientMixin.DATATYPE.UINT32, length=4),
Register(self.id, "SOCStateOfcharge", 0x12A6, ModbusClientMixin.DATATYPE.UINT16),
Register(self.id, "SOCCapRemain", 0x12A8, ModbusClientMixin.DATATYPE.INT32, 0.001),
Register(self.id, "SOCFullChargeCap", 0x12AC, ModbusClientMixin.DATATYPE.UINT32, 0.001),
Register(self.id, "SOCCycleCount", 0x12B0, ModbusClientMixin.DATATYPE.UINT32),
Register(self.id, "SOCCycleCap", 0x12B4, ModbusClientMixin.DATATYPE.UINT32, .001),
Register(self.id, "BatVol", 0x12E4, ModbusClientMixin.DATATYPE.UINT16, .01),
Register(self.id, "ChargingStatus", 0x12C0, ModbusClientMixin.DATATYPE.UINT16),
Register(self.id, "ManufacturerDeviceID", 0x1400, ModbusClientMixin.DATATYPE.STRING, None, 8),
Register(self.id, "HardwareVersion", 0x1410, ModbusClientMixin.DATATYPE.STRING, None, 4),
Register(self.id, "SoftwareVersion", 0x1418, ModbusClientMixin.DATATYPE.STRING, None, 4),
Register(self.id, "BatTempMos", 0x128A, ModbusClientMixin.DATATYPE.UINT16, .1),
Register(self.id, "BatTemp1", 0x129C, ModbusClientMixin.DATATYPE.UINT16, .1),
Register(self.id, "BatTemp2", 0x129E, ModbusClientMixin.DATATYPE.UINT16, .1),
Register(self.id, "BatTemp3", 0x12F8, ModbusClientMixin.DATATYPE.UINT16, .1),
Register(self.id, "BatTemp4", 0x12FA, ModbusClientMixin.DATATYPE.UINT16, .1),
Register(self.id, "BatTemp5", 0x12FC, ModbusClientMixin.DATATYPE.UINT16, .1)
]
self.cellVoltages = None
self.binary_sensors = ['BatChargeEN', 'BatDisChargeEN', 'BatBalanceEN']
self.device_options = ['HeaterEN', 'DisableTempSensor', 'GpsHeartBeat', 'PortSwitch', 'LcdAlwaysOn', 'SpecialCharger', 'SmartSleep', 'DisablePclModule', 'TimedStoredData', 'ChargingFloatEN',"UnknownOption",'DryArmIntermittent','DischargeOCP2','DischargeOCP3']
self.device_alarms = ['AlarmWireRes','MosOtp', 'CellQuantity', 'CurSensorErr', 'CellOVP', 'BatOVP', 'ChargeOVP', 'ChargeSCP', 'ChargeOTP', 'ChargeUTP',
'CpuAuxCommErr', 'CellUVP', 'BatUVP', 'DischargeOCP', 'DischargeSCP', 'DischargeOTP', 'ChargeMos', 'DischargeMos', 'GpsDisconnected', 'ModifyPasswordTime',
'DischargeOnFail', 'BatteryOverTemp','TemperatureSensorAbnormality',"PclModuleAbnormality"]
def connect(self):
if self.connection == None:
self.connection = ModbusClient.ModbusSerialClient(port=self.port, stopbits=1, bytesize=8, parity='N', baudrate=115200, timeout=1)
if self.connection.connect():
print("Successfully connected to BMS id %d" % self.id)
else:
print("Cannot connect to BMS id %d" % self.id)
self.connection = None
return self.connection
def setChargeMode(self, mode):
register = self.getRegister("BatChargeEN")
register.setValue(self.connection, mode)
def setDischargeMode(self, mode):
register = self.getRegister("BatDisChargeEN")
register.setValue(self.connection, mode)
def getCellVoltages(self, reload=False):
if self.cellVoltages == None or reload == True:
count = self.getRegister('CellCount').value
values = []
recv = self.connection.read_holding_registers(address=0x1200, count=count, slave=self.id)
if not isinstance(recv, pymodbus.pdu.register_message.ReadHoldingRegistersResponse):
return None
values = self.connection.convert_from_registers(recv.registers, data_type=self.connection.DATATYPE.UINT16)
for i in range(0, count):
r = Register(self.id, f'CellVol{i}', 0x1200+i*2, ModbusClient.ModbusSerialClient.DATATYPE.UINT16, 0.001)
r.value = round(values[i]*0.001,3)
values[i] = r.value
self.registers.append(r)
self.cellVoltages = values
return self.cellVoltages
def dump(self, dic = {}):
#self.getCellVoltages()
d = super().dump()
topic_soc = "bms-%d" % self.id
d["name"] = self.name
dic[topic_soc] = d
self.normalize_values(dic[topic_soc])
return dic
def __str__(self):
self.getCellVoltages()
return super().__str__()
def formattedOutput(self):
bms_model = self.getRegister('ManufacturerDeviceID').value
bms_hw_version = self.getRegister('HardwareVersion').value
bms_sw_version = self.getRegister('SoftwareVersion').value
alarms = self.getRegister('Alarms').value
# We skip the first byte, as it can be 0, 1 and 2
# A 100% SOC will give us a value of 100 with first byte set to 0, 356 when set to 1 and 612 when set to 2
soc = self.getRegister('SOCStateOfcharge').value & 0x0FF
cycle_count = self.getRegister('SOCCycleCount').value
pack_voltage = self.getRegister('BatVol').value
cell_count = self.getRegister('CellCount').value
cell_avg_voltage = self.getRegister('CellVolAve').value
battery_current = self.getRegister('BatCurrent').value
discharge_enabled = self.getRegister('BatDisChargeEN').value
pack_capacity = self.getRegister('SOCFullChargeCap').value
pack_remaining = self.getRegister('SOCCapRemain').value
status = "charging" if battery_current > 0 else "discharging"
options = self.getRegister("DeviceOptions").value
s = f"== {self.name} (id {self.id}) - {bms_model} (v{bms_hw_version}) - sw v{bms_sw_version}) ==\n"
s += f"Alarms?\t\t\t{alarms}\n"
s += f"SOC:\t\t\t{soc} ({cycle_count} cycle(s))\n"
s += f"Voltage:\t\t{pack_voltage:.2f}v\n"
s += f"Cell Voltages:\t\t{str(self.getCellVoltages())}\n"
s += f"Cell Average Voltage:\t{cell_avg_voltage:.3f} ({cell_count} cells)\n"
s += f"Battery Current:\t{battery_current:.3f}A ({status} {pack_voltage*battery_current:.2f}W)\n"
s += f"Discharge Enabled?\t{discharge_enabled}\n"
s += f"Remaining Capacity:\t{pack_remaining:.2f}Ah ({pack_capacity}Ah capacity)"
return s
# def update_alarms(self, alarms_value):
# alarms_bits = list(bin(options_value)[2:])
# res = {}
# for i in range(len(self.device_options)):
# res[self.device_alarms[i]] = "ON" if alarms_value[i] else "OFF"
# return res
# def update_options(self, options_value):
# options_bits = list(bin(options_value)[2:])
# res = {}
# for i in range(len(self.device_options)):
# res[self.device_options[i]] = "ON" if options_value[i] == 1 else "OFF"
# return res
def parse_bits(self, key_names, bytes_object):
bits = bin(bytes_object)[2:].zfill(32)
bit_flags = list(reversed(bits))
res = {}
for pos in range(len(key_names)):
res.update({key_names[pos]: "ON" if bit_flags[pos] == "1" else "OFF"})
return res
def normalize_values(self, dic):
for topic in self.binary_sensors:
dic[topic] = True if dic[topic] == 1 else False
dic['DeviceOptions'] = self.parse_bits(self.device_options,dic['DeviceOptions'])
dic["Alarms"] = self.parse_bits(self.device_alarms, dic["Alarms"])
# dic.update(self.update_options(dic['DeviceOptions']))
# dic.pop("DeviceOptions")
# dic.update(self.update_alarms(dic['Alarms']))
# dic.pop('Alarms')

47
ModbusDevice.py Normal file
View File

@ -0,0 +1,47 @@
#
# Copyright (C) 2025 Extrafu <extrafu@gmail.com>
#
# This file is part of BerryBMS.
#
# BerryBMS is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3, or (at your option) any
# later version.
#
import json
class ModbusDevice(object):
def __init__(self, id):
self.id = id
self.connection = None
def disconnect(self):
if self.connection != None:
#print("Closing connnection...")
self.connection.close()
def getAllRegisters(self):
return self.registers
# Return the initialized value of a register
def getRegister(self, name):
for register in self.registers:
if name == register.name:
register.getValue(self.connection)
return register
return None
def dump(self):
values = {}
for register in self.registers:
values[register.name] = register.getValue(self.connection)
return values
def __str__(self):
json_formatted = json.dumps(self.dump(), indent=2)
return json_formatted
def formattedOutput(self):
return ""

73
Register.py Normal file
View File

@ -0,0 +1,73 @@
#
# Copyright (C) 2025 Extrafu <extrafu@gmail.com>
#
# This file is part of BerryBMS.
#
# BerryBMS is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3, or (at your option) any
# later version.
#
import pymodbus.client as ModbusClient
from pymodbus.client.mixin import ModbusClientMixin
import pymodbus.exceptions
import pymodbus.payload
import time
class Register(object):
def __init__(self,
id,
name,
address,
type,
scale=1,
length=0
):
self.id = id
self.name = name
self.address = address
self.type = type
self.scale = scale
self.length = length
self.value = None
def getValue(self, c, reload=False):
if reload == True:
self.value = None
if self.value != None:
return self.value
use_scale = True
if self.type == ModbusClientMixin.DATATYPE.STRING:
use_scale = False
elif (self.type == ModbusClientMixin.DATATYPE.INT16 or self.type == ModbusClientMixin.DATATYPE.UINT16):
self.length = 1
elif (self.type == ModbusClientMixin.DATATYPE.INT32 or self.type == ModbusClientMixin.DATATYPE.UINT32):
self.length = 2
# elif self.type == BITS:
# use_scale = False
recv = c.read_holding_registers(address=self.address, count=self.length, slave=self.id)
if not isinstance(recv, pymodbus.pdu.register_message.ReadHoldingRegistersResponse):
return None
self.value = c.convert_from_registers(recv.registers, data_type=self.type)
if use_scale:
self.value = self.value * self.scale
time.sleep(0.05)
return self.value
def setValue(self, c, value):
raw_value = c.convert_to_registers(value, self.type)
r = c.write_registers(self.address, raw_value, slave=self.id)
time.sleep(0.05)
return r

0
__init__.py Normal file
View File

@ -1 +0,0 @@
Subproject commit ff8cff8b86eafdfbe3493f220d87787d17551a80

154
berrybms.py Normal file
View File

@ -0,0 +1,154 @@
#!/usr/bin/python3
#
# Copyright (C) 2025 Extrafu <extrafu@gmail.com>
#
# This file is part of BerryBMS.
#
# BerryBMS is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3, or (at your option) any
# later version.
#
import sys
import time
import yaml
import logging
import json
import signal
import paho.mqtt.client as paho
from ConextAGS import ConextAGS
from ConextInsightHome import ConextInsightHome
from JKBMS import JKBMS
from Register import Register
# Global variables to enable cleanups in signal handler
all_modbus_devices = []
paho_client = None
def cleanup(_signo, _stack_frame):
print("Cleaning up before being terminated!")
global paho_client
if paho_client != None:
paho_client.disconnect()
global all_modbus_devices
for device in all_modbus_devices:
device.disconnect()
sys.exit(0)
def main(daemon):
# setup the signal handler
signal.signal(signal.SIGTERM, cleanup)
# load yaml config
f = open("config.yaml","r")
config = yaml.load(f, Loader=yaml.SafeLoader)
while True:
# Connect to MQTT server. We do it each time since it's not costly
# and avoids long keepalive if the updateinterval is set to a high value.
global paho_client
paho_client = paho.Client()
try:
paho_client.connect(config['mqtt']['host'], int(config['mqtt']['port']), 60)
except:
print("Couldn't connect to the MQTT broker, the GUI part if used, won't be updated.")
paho_client = None
global all_modbus_devices
all_devices = {}
all_bms = config['bms']
average_voltage = 0
average_soc = 0
active_bms = 0
total_used_capacity = 0
highest_soc = 0
highest_id = 0
lowest_soc = 100
lowest_id = 0
if all_bms != None:
for key in all_bms.keys():
bms = all_bms[key]
bms_id = bms['id']
bms_port = bms['port']
jkbms = JKBMS(key, bms_id, bms_port)
c = jkbms.connect()
if c == None:
continue
#print(jkbms)
print(jkbms.formattedOutput(), '\n')
all_modbus_devices.append(jkbms)
active_bms += 1
soc = jkbms.getRegister('SOCStateOfcharge').value & 0x0FF
average_soc += soc;
# We adjust the lowest/highest SOC
if soc > highest_soc:
highest_soc = soc
highest_id = bms_id
if lowest_soc > soc:
lowest_soc = soc
lowest_id = bms_id
average_voltage += jkbms.getRegister('BatVol').value
total_used_capacity += (jkbms.getRegister('SOCFullChargeCap').value - jkbms.getRegister('SOCCapRemain').value)
# Publish all BMS values in MQTT
jkbms.publish(all_devices)
if config['insighthome'] != None:
conext = ConextInsightHome(config['insighthome']['host'], config['insighthome']['port'], config['insighthome'].get('ids', None))
all_modbus_devices.append(conext)
c = conext.connect()
devices = conext.allDevices()
for device in devices:
device.publish(all_devices)
#print("%s: %s\n" % (type(device).__name__, device))
print(device.formattedOutput(), '\n')
# Publish all values in MQTT
if paho_client != None:
paho_client.publish("berrybms", json.dumps(all_devices))
paho_client.disconnect()
# We disconnect from all Modbus devices
for device in all_modbus_devices:
device.disconnect()
all_modbus_devices = []
print("== Global BMS Statistics ==")
average_voltage = average_voltage/active_bms
average_soc = average_soc/active_bms
print(f"Average Voltage:\t{average_voltage:.2f}v")
print(f"Average SOC:\t\t{average_soc:.1f}%")
print(f"Total Used Capacity:\t{total_used_capacity:.2f} Ah (~ {(total_used_capacity*average_voltage/1000):.2f} KWh)")
print(f"Lowest SOC:\t\t{lowest_id} ({lowest_soc}%) Highest: {highest_id} ({highest_soc}%)")
if daemon:
updateinterval = config.get('updateinterval')
if updateinterval == None:
updateinterval = 30
print(f'Sleeping for {updateinterval} seconds...')
time.sleep(updateinterval)
else:
break
if __name__ == "__main__":
args = sys.argv[1:]
daemon = False
#print(args)
if len(args) == 1 and args[0] == '-d':
daemon = True
main(daemon)

366
berrydash.py Normal file
View File

@ -0,0 +1,366 @@
#!/usr/bin/python3
#
# Copyright (C) 2025 Extrafu <extrafu@gmail.com>
#
# This file is part of BerryBMS.
#
# BerryBMS is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3, or (at your option) any
# later version.
#
import dash
from dash import Dash, html, dcc, Input, Output
import dash_daq as daq
import dash_bootstrap_components as dbc
from dash_bootstrap_templates import load_figure_template
import plotly.graph_objects as go
import time
import sys
from flask_mqtt import Mqtt
import json
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.DARKLY, dbc.icons.FONT_AWESOME])
server = app.server
load_figure_template("darkly")
server.config['MQTT_BROKER_URL'] = 'localhost'
server.config['MQTT_BROKER_PORT'] = 1883
mqtt = Mqtt()
mqtt.init_app(app)
all_battmon = {}
all_bms = {}
all_mppt = {}
all_xw = {}
@mqtt.on_connect()
def handle_connect(client, userdata, flags, reason_code):
print("Connected with result code "+ str(reason_code))
mqtt.subscribe("berrybms")
@mqtt.on_message()
def handle_mqtt_message(client, userdata, message):
devices = json.loads(message.payload)
#print("Got values (%s) from MQTT from topic: %s" % (str(devices), message.topic))
for key in devices.keys():
if key.startswith("battmon"):
global all_battmon
all_battmon[key] = devices[key]
elif key.startswith("bms"):
global all_bms
all_bms[key] = devices[key]
elif key.startswith("mppt"):
global all_mppt
all_mppt[key] = devices[key]
elif key.startswith("xw"):
global all_xw
all_xw[key] = devices[key]
def buildConextGauge():
if len(all_xw) == 0:
return None
active_power = 0
#battery_power = 0
ac_load_active_power = 0
mppt_dc_output_power = 0
pv_input_active_today = 0
grid_ac_input_power = 0
grid_active_today = 0
generator_ac_power = 0
generator_active_today = 0
charge_dc_power = 0
charge_dc_current = 0
for key in all_xw.keys():
xw = all_xw[key]
#battery_power += xw['BatteryPower']
ac_load_active_power += xw['LoadACPowerApparent']
grid_ac_input_power += xw['GridACInputPower']
grid_active_today += xw['GridInputActiveToday']
generator_ac_power += xw['GeneratorACPowerApparent']
generator_active_today += xw['GeneratorInputActiveToday']
charge_dc_power += xw['ChargeDCPower']
charge_dc_current += xw['ChargeDCCurrent']
for key in all_mppt.keys():
mppt = all_mppt[key]
mppt_dc_output_power += mppt['DCOutputPower']
pv_input_active_today += mppt['PVInputActiveToday']
active_power =(mppt_dc_output_power+grid_ac_input_power+generator_ac_power)-(ac_load_active_power)
gauge = daq.Gauge(
showCurrentValue=True,
color={"gradient": True, "ranges": {"red": [-10000, -1000], "yellow": [-1000, 1000], "green": [1000, 10000]}},
id="active-power-gauge",
max=10000,
min=-10000,
size=200,
units="Watts",
style={'display': 'block', 'margin-bottom': -80, 'margin-top': -15},
value=active_power,
digits=0 # available after 0.5, see https://github.com/plotly/dash-daq/pull/117/files
)
row1 = html.Tr([html.Td("AC Load Active"), html.Td(f'{ac_load_active_power} W')])
row2 = html.Tr([html.Td("MPPT DC Ouput"),
html.Td(['{} W ({} '.format(mppt_dc_output_power, time.strftime("%H:%M:%S", time.gmtime(pv_input_active_today))),
html.I(className='fa-solid fa-hourglass'),
')'])
])
row3 = html.Tr([html.Td("Grid AC Input"),
html.Td(['{} W ({} '.format(grid_ac_input_power, time.strftime("%H:%M:%S", time.gmtime(grid_active_today))),
html.I(className='fa-solid fa-hourglass'),
')'])
])
row4 = html.Tr([html.Td("Generator AC Input"),
html.Td(['{} W ({} '.format(generator_ac_power, time.strftime("%H:%M:%S", time.gmtime(generator_active_today))),
html.I(className='fa-solid fa-hourglass'),
')'])
])
row5 = html.Tr([html.Td("XW+ Charge DC"), html.Td(f'{charge_dc_power} W ({charge_dc_current:.2f} A)')])
table_body_left = [html.Tbody([row1, row2, row3, row4, row5])]
card = dbc.Card(
[
dbc.CardHeader(children=[html.B("Active Power", style={'font-size':'13px'})]),
dbc.CardBody([
gauge,
dbc.Table(table_body_left, dark=True, striped=True, bordered=False, hover=True, style={'font-size': '11px'})
], style={
'padding': '0'
})
],
#color="primary",
inverse=True,
style={"width": "16rem"}
)
return card
def buildConextStats():
if len(all_bms) == 0 or len(all_battmon) == 0:
return None
average_bms_soc = 0
average_battmon_soc = 0
average_bms_voltage = 0
average_bms_current = 0
average_battmon_voltage = 0
average_battmon_current = 0
removed_bms_capacity = 0
remaining_bms_capacity = 0
total_bms_capacity = 0
removed_battmon_capacity = 0
remaining_battmon_capacity = 0
total_battmon_capacity = 0
for key in all_bms.keys():
bms = all_bms[key]
average_bms_soc += (bms['SOCStateOfcharge'] & 0x0FF)
average_bms_voltage += bms['BatVol']
average_bms_current += bms['BatCurrent']
remaining_bms_capacity += bms['SOCCapRemain']
total_bms_capacity += bms['SOCFullChargeCap']
average_bms_soc /= len(all_bms)
average_bms_voltage /= len(all_bms)
removed_bms_capacity = total_bms_capacity-remaining_bms_capacity
if len(all_battmon):
for key in all_battmon.keys():
battmon = all_battmon[key]
average_battmon_soc += battmon['BatterySOC']
average_battmon_voltage += battmon['BatteryVoltage']
average_battmon_current += battmon['BatteryCurrent']
remaining_battmon_capacity += battmon['BatteryCapacityRemaining']
removed_battmon_capacity += battmon['BatteryCapacityRemoved']
total_battmon_capacity += battmon['BatteryCapacity']
average_battmon_soc /= len(all_battmon)
average_battmon_voltage /= len(all_battmon)
row1 = html.Tr([html.Td("Average SOC (BMS)"), html.Td(f'{average_bms_soc:.2f} %')])
row2 = html.Tr([html.Td("Average SOC (BattMon)"), html.Td(f'{average_battmon_soc:.2f} %')])
row3 = html.Tr([html.Td("Average voltage/current (BMS)"), html.Td(f'{average_bms_voltage:.2f} v / {average_bms_current:.2f} A')])
row4 = html.Tr([html.Td("Average voltage/current (BattMon)"), html.Td(f'{average_battmon_voltage:.2f} v / {average_battmon_current:.2f} A')])
row5 = html.Tr([html.Td("Removed capacity (BMS)"), html.Td(f'{removed_bms_capacity:.1f} Ah ({remaining_bms_capacity:.1f} Ah remaining of {total_bms_capacity:.0f} Ah)')])
row6 = html.Tr([html.Td("Removed capacity (BattMon)"), html.Td(f'{removed_battmon_capacity:.1f} Ah ({remaining_battmon_capacity:.1f} Ah remaining of {total_battmon_capacity:.0f} Ah)')])
table_body_right = [html.Tbody([row1, row2, row3, row4, row5, row6])]
table = dbc.Table(table_body_right, dark=True, striped=True, bordered=False, hover=True, responsive=True, style={'font-size': '11px'})
card = dbc.Card(
[
dbc.CardHeader(children=[html.B("BMS/BattMon Statistics", style={'font-size':'13px'})]),
dbc.CardBody([
table
], style={
'padding': '0'
})
],
style={"width": "16rem", "height": "100%"}
)
return card
def buildBMSGauge(key, bms):
cellCount = bms.get("CellCount")
highestCellVoltage = 0
lowestCellVoltage = sys.maxsize
highestCellIndex = 0
lowestCellIndex = 0
badges = []
tooltips = []
for i in range(0,cellCount):
cellVoltage = round(float(bms.get(f'CellVol{i}')),3)
if cellVoltage < lowestCellVoltage:
lowestCellIndex = i
lowestCellVoltage = cellVoltage
elif cellVoltage >= highestCellVoltage:
highestCellIndex = i
highestCellVoltage = cellVoltage
b = dbc.Badge('{:.3f}v'.format(cellVoltage), color="primary", id=f'{key}-cellVol{i}')
badges.append(b)
tooltips.append(dbc.Tooltip(f'Cell {i+1}', target=f'{key}-cellVol{i}'))
badges[highestCellIndex].color = "green"
badges[lowestCellIndex].color = "warning"
warning_icon_color = 'gray'
warning_message = 'No warning'
if bms.get("Alarms") > 0:
warning_icon_color = 'red'
card = dbc.Card(
[
dbc.CardHeader(children=[
html.B("{} - {} (v{}) ".format(bms["name"], bms['ManufacturerDeviceID'], bms['SoftwareVersion']), style={'font-size':'13px'}),
html.I(className='fa-solid fa-triangle-exclamation', style={"color": warning_icon_color}, id=f'{key}-warning'),
dbc.Tooltip(warning_message, target=f'{key}-warning')
]),
dbc.CardBody([
daq.Gauge(
showCurrentValue=True,
color={"gradient": True, "ranges": {"red": [0, 25], "yellow": [25, 65], "green": [65, 100]}},
#label="{} - {} (v{})".format(key, bms['ManufacturerDeviceID'], bms['SoftwareVersion']),
labelPosition='top',
id=f'soc-gauge-{key}',
max=100,
size=200,
units="{:.2f} Ah of {:.0f} Ah".format(bms['SOCCapRemain'], bms['SOCFullChargeCap']),
style={'display': 'block', 'margin-bottom': -80, 'margin-top': -30},
value=bms['SOCStateOfcharge'],
digits=0 # available after 0.5, see https://github.com/plotly/dash-daq/pull/117/files
),
html.Span("{} at {:.2f}A/{:.0f}W ({} cycles)".format(("Charging" if bms['BatCurrent'] > 0 else "Discharging"),
abs(bms['BatCurrent']),
abs(bms['BatCurrent'])*bms['BatVol'],
bms['SOCCycleCount']),
style={'font-size':'11px'}),
html.Br(),
html.Span(badges+tooltips)
])
],
#color="primary",
inverse=True,
style={"width": "16rem", 'padding': '0'}
)
return card
def buildBMSGauges():
cols = []
for key in all_bms.keys():
bms = all_bms[key]
card = buildBMSGauge(key, bms)
cols.append(dbc.Col(card))
return dbc.Row(id='bms-gauges', children=cols, className="g-0")
app.layout = html.Div([
dcc.Interval(
id='interval-component',
interval=5*1000,
n_intervals=0
),
html.Div(dbc.Row([
dbc.Col(id='conext-gauge', children=[buildConextGauge()]),
dbc.Col(id='conext-stats', children=[buildConextStats()]),
dbc.Col(dbc.Tabs(id='tabs', active_tab='tab-0'))
],
className="g-0")),
html.Div(buildBMSGauges())
])
@app.callback(Output(component_id='conext-gauge', component_property='children'),
Input(component_id='interval-component', component_property='n_intervals'))
def update_conext_gauge(n):
return buildConextGauge()
@app.callback(Output(component_id='conext-stats', component_property='children'),
Input(component_id='interval-component', component_property='n_intervals'))
def update_conext_stats(n):
return buildConextStats()
@app.callback(Output(component_id='bms-gauges', component_property='children'),
Input(component_id='interval-component', component_property='n_intervals'))
def update_bms_gauges(n):
return buildBMSGauges()
@app.callback(Output(component_id='tabs', component_property='children'),
Input(component_id='interval-component', component_property='n_intervals'))
def update_tabs(n):
tabs = []
if len(all_mppt) == 0:
return None
x_axis = ['Battery Out', 'Battery In', 'PV In', 'Generator In', 'Grid In', 'Load Out']
tab_index = 0
for period in ['ThisHour', 'Today', 'ThisWeek', 'ThisMonth']:
y_axis = [0, 0, 0, 0, 0, 0]
# Get data from the MPPT charge controllers
for key in all_mppt.keys():
mppt = all_mppt[key]
y_axis[1] += mppt[f'EnergyToBattery{period}']
y_axis[2] += mppt[f'EnergyFromPV{period}']
# Get data from the XW inverters
for key in all_xw.keys():
xw = all_xw[key]
y_axis[0] += xw[f'EnergyToBattery{period}']
y_axis[1] += xw[f'EnergyFromBattery{period}']
y_axis[3] += xw[f'GeneratorInputEnergy{period}']
y_axis[4] += xw[f'GridInputEnergy{period}']
y_axis[5] += xw[f'LoadOutputEnergy{period}']
for i in range(0,6):
y_axis[i] = round(y_axis[i], 2)
colors = ['crimson','aqua','aquamarine','aquamarine','aquamarine','crimson']
fig = go.Figure(data=[go.Bar(
x=x_axis,
y=y_axis,
text=y_axis,
textposition='auto',
marker_color=colors
)]
)
fig.update_layout(margin=dict(l=5, r=5, t=5, b=5))
fig.update_yaxes(title='kWh')
tab = dbc.Tab(id=f'tab-{tab_index}',
label=period.replace('This',''),
active_label_style={"color": "#F39C12"},
children=[dcc.Graph(id=f'graph-{period}', figure=fig, config={'displayModeBar': False}, style={'width': '50vw', 'height': '50vh'})]
)
tabs.append(tab)
tab_index += 1
return tabs
if __name__ == '__main__':
app.run_server(debug=False, port=8080, host='0.0.0.0', use_reloader=False)

86
homeassistant.py Normal file
View File

@ -0,0 +1,86 @@
from json import dumps
from os.path import join
class ha_mqtt_formatter():
def __init__(
self,
manufacturer : str,
model : str,
name : str,
identifiers : list,
availability_topic : list,
device_class : str
):
self.base = {
'device' : {
'manufacturer': manufacturer,
'model' : model,
'name' : name,
'identifiers' : identifiers,
},
'device_class' : device_class,
'availability' : [
{"topic" : x,
"value_template": "{{ value_json.state }}"
} for x in availability_topic
]
}
def binary_sensor(self, name, unique_id, payload_on, payload_off, state_topic, entity_category='diagnostic'):
update = self.base.copy()
update.update(
{
'name' : name,
'unique_id' : unique_id,
'payload_on' : payload_on,
'payload_off' : payload_off,
'state_topic' : state_topic,
'entity_category' : entity_category,
#"value_template": "{{ value_json.state }}"
}
)
return update
def switch(self, unique_id, name, payload_on, payload_off, state_topic, entity_category='config'):
update = self.base.copy()
update.update(
{
'name' : name,
'unique_id' : unique_id,
'payload_on' : payload_on,
'payload_off' : payload_off,
'state_topic' : state_topic,
"command_toppic" : join(self.base["base_topic", 'name', 'set']),
'entity_category' : entity_category,
# "value_template": "{{ value_json.state }}"
}
)
return update
def sensor(self, name, unique_id, state_class, state_topic, unit, entity_category='diagnostic'):
update = self.base.copy()
update.update(
{
'name' : name,
'unique_id' : unique_id,
'unit' : unit,
'state_topic' : state_topic,
'entity_category' : entity_category,
'state_class' : state_class,
}
)
return update
def number(self, unique_id, name, state_topic, unit, entity_category='config'):
update = self.base.copy()
update.update(
{
'name' : name,
'unique_id' : unique_id,
'unit' : unit,
'entity_category' : entity_category,
'state_topic' : state_topic,
# "value_template": f"{{ value_json.{name} }}"
}
)
return update

50
test.py Normal file
View File

@ -0,0 +1,50 @@
from JKBMS import JKBMS
from Register import Register
from homeassistant import ha_mqtt_formatter
import paho.mqtt.client as paho
import json
HOMEASSISTANT_TOPIC = 'homeassistant'
BASE_TOPIC = 'jk-bms'
# switches = ['BatChargeEN', 'BatDisChargeEN', 'BatBalanceEN', 'HeaterEN', 'DisableTempSensor', 'GpsHeartBeat' , 'PortSwitch', 'LcdAlwaysOn', 'SpecialCharger', 'SmartSleep', 'DisablePclModule', 'TimedStoredData', 'ChargingFloatEN']
# sensors_v = [, 'CellVolAve', 'BatCurrent', 'SOCStateOfcharge', 'SOCCapRemain', , 'SOCCycleCap']
# sensors_c = ['CellCount', 'SOCCycleCount', ]
# sensors_a =
# numbers = ['CellUnderVoltProtectionValue', '100PercentSoCVoltageValue', 'ZeroPercentSoCVoltageValue', 'CellChargeOverCurrentProtection', 'CellDischargeOverCurrentProtection', 'SOCFullChargeCap']
#setup
jkbms = JKBMS(BASE_TOPIC, 0x01, "/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0")
# paho_client = paho.Client(client_id='bms-monitor', callback_api_version=paho.CallbackAPIVersion.VERSION2)
# paho_client.username_pw_set('growatt','growatt_password')
# paho_client.connect('homeassistant.internal', 1883, 60)
# mqtt_ha_base = ha_mqtt_formatter("JiKong", 'PB2A16S20A', f'bms-{jkbms.id}', [f'{BASE_TOPIC}_bms-{jkbms.id}'], [f'{BASE_TOPIC}/bms-{jkbms.id}/availability'], 'battery')
try:
if not jkbms.connect():
raise Exception.with_traceback()
# for cell in range(len(jkbms.getRegister('CellCount').value)):
# paho_client.publish(f'{HOMEASSISTANT_TOPIC}/sensor', mqtt_ha_base.sensor(f'{BASE_TOPIC}_bms-{jkbms.id}_CellVol{cell}', f'CellVol{cell}', "measurement", f'{BASE_TOPIC}/bms-{jkbms.id}', 'v',))
# for switch in switches:
# paho_client.publish(f'{HOMEASSISTANT_TOPIC}/switch', mqtt_ha_base.switch(f'{BASE_TOPIC}_bms-{jkbms.id}_{switch}', switch, True, False, f'{BASE_TOPIC}/bms-{jkbms.id}'))
# for sensor in sensors:
# paho_client.publish(mqtt_ha_base.sensor(f'{BASE_TOPIC}_bms-{jkbms.id}_{sensor}', sensor, "measurement", f'{BASE_TOPIC}/bms-{jkbms.id}', ))
except Exception as ex:
print("Couldn't connect to the MQTT broker, the GUI part if used, won't be updated.")
paho_client = None
#main
try:
jkbms.getCellVoltages()
updates = jkbms.dump()
print()
# if paho_client != None:
# paho_client.publish(f"bms-monitor/{list(updates.keys())[0]}", json.dumps(updates[list(updates.keys())[0]]))
# paho_client.on_subscribe
# paho_client.disconnect()
except Exception as ex:
print(ex)