bms/berrydash.py
2025-10-14 20:47:40 -05:00

366 lines
14 KiB
Python

#!/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)