add files
This commit is contained in:
parent
a7959663fb
commit
88970774a4
184
JKBMS.py
Normal file
184
JKBMS.py
Normal 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
47
ModbusDevice.py
Normal 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
73
Register.py
Normal 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
0
__init__.py
Normal file
1
berrybms
1
berrybms
@ -1 +0,0 @@
|
||||
Subproject commit ff8cff8b86eafdfbe3493f220d87787d17551a80
|
||||
154
berrybms.py
Normal file
154
berrybms.py
Normal 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
366
berrydash.py
Normal 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
86
homeassistant.py
Normal 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
50
test.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user