#!/usr/bin/python3 # # Copyright (C) 2025 Extrafu # # 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)