diff --git a/JKBMS.py b/JKBMS.py new file mode 100644 index 0000000..757a16a --- /dev/null +++ b/JKBMS.py @@ -0,0 +1,184 @@ +# +# 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 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') \ No newline at end of file diff --git a/ModbusDevice.py b/ModbusDevice.py new file mode 100644 index 0000000..9a703bc --- /dev/null +++ b/ModbusDevice.py @@ -0,0 +1,47 @@ +# +# 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 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 "" \ No newline at end of file diff --git a/Register.py b/Register.py new file mode 100644 index 0000000..602c7f5 --- /dev/null +++ b/Register.py @@ -0,0 +1,73 @@ +# +# 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 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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/berrybms b/berrybms deleted file mode 160000 index ff8cff8..0000000 --- a/berrybms +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ff8cff8b86eafdfbe3493f220d87787d17551a80 diff --git a/berrybms.py b/berrybms.py new file mode 100644 index 0000000..cce9e93 --- /dev/null +++ b/berrybms.py @@ -0,0 +1,154 @@ +#!/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 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) diff --git a/berrydash.py b/berrydash.py new file mode 100644 index 0000000..d62ba85 --- /dev/null +++ b/berrydash.py @@ -0,0 +1,366 @@ +#!/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) \ No newline at end of file diff --git a/homeassistant.py b/homeassistant.py new file mode 100644 index 0000000..ae337f7 --- /dev/null +++ b/homeassistant.py @@ -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 \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..349970f --- /dev/null +++ b/test.py @@ -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)