Files
bmspy/bmspy/jbd_proto.py
T
tim f36bb2510c Rename bms_temperature_celcius to bms_temperature_celsius
Correct the spelling in the JBD and EcoFlow snapshots, the diagnostic tool, and the tests. UPS.from_dict maps the old name to the new name, so a client still reads data from an older server during an upgrade.

InfluxDB and Prometheus now receive this measurement under the new name.
2026-10-10 09:03:33 +02:00

377 lines
13 KiB
Python

"""JBD checksums, byte conversions, and response decoding. No serial I/O."""
from bmspy.classes import BMSInfoField, BMSMultiField, BMSScalarField
from bmspy.jbd_snapshot import JBDBMS
from bmspy.utilities import debugger
def calculate_checksum(msg: bytes | bytearray) -> str:
checksum = ""
return checksum
def verify_checksum(data: bytes | bytearray, checksum: bytes) -> bool:
"""Verify a JBD BMS packet checksum (sum complement + 1, big-endian)."""
# (data + length + command code) checksum, then complement, then add 1, high bit first, low bit last
# data should have start/rw stripped
s = 0
for i in data:
s += i
s = (s ^ 0xFFFF) + 1
chk = bytes_to_digits(checksum[0], checksum[1])
return s == chk
def convert_to_signed(x: int) -> int:
"""Convert a JBD current value (possibly encoded) to a signed integer."""
# For values below 1024, these seem to be actual results
# For values above 1024, these seem to be encoded to account for high and negative floats
max_uint = 1024
if x >= max_uint:
return (x - 2**9) % 2**10 - 2**9
else:
return x
def bytes_to_digits(high: int, low: int) -> int:
"""Combine two bytes (high, low) into a single 16-bit unsigned integer."""
result = high
result <<= 8
result = result | low
return result
def bytes_to_date(high: int, low: int) -> str:
"""Decode a JBD-encoded manufacture date from two bytes into 'YYYY-MM-DD' format."""
result = bytes_to_digits(high, low)
day = result & 0x1F
mon = (result >> 5) & 0x0F
year = 2000 + (result >> 9)
return "{:04d}-{:02d}-{:02d}".format(year, mon, day)
def parse_03_response(response: bytearray, debug: int = 0) -> JBDBMS | bool:
"""Parse a JBD BMS 0x03 (basic info) response packet into a JBDBMS dataclass."""
# Response is 34 bytes:
# 00 begin: \xDD
# 01 r/w: \xA5
# 02 status: \x00 = correct; \x80 = incorrect
# 03 length (usually 27)
# 04 data (size of length)
# ...
# length+4 checksum
# length+5 checksum
# length+6 end \x77
if bytes([response[0]]) != b"\xdd":
debugger(
"parse_03_response ERROR: first byte not found: {0}".format(response[0])
)
return False
if bytes([response[2]]) == b"\x80":
debugger(
"parse_03_response ERROR: error byte returned from BMS: {0}".format(
response[2]
)
)
return False
data_len = response[3]
if debug > 2:
debugger(
"parse_03_response: data length (trimming 4 bytes): {0}".format(data_len)
)
# The checksum is two bytes, offset by data_len + 4
# Five bytes at the front of data: begin; rw; status, command; length
# The checksum should check command, length, and data: [3] to [3+data_len+1]
first = data_len + 4
second = data_len + 5
if second > len(response):
debugger("parse_03_response ERROR: primary response checksum not found")
return False
checksum = bytes([response[first], response[second]])
if not verify_checksum(response[3:first], checksum):
debugger("parse_03_response ERROR: failed to validate received checksum")
return False
if data_len == 0: # pragma: no cover
return False
result = JBDBMS()
vtot = bytes_to_digits(response[4], response[5]) * 0.01
result.bms_voltage_total_volts = BMSScalarField(
help="Total Voltage", raw_value=vtot, value="{:.2f}".format(vtot), units="V"
)
if debug > 1:
debugger(" Total voltage: {:.2f}V".format(vtot))
current = convert_to_signed(bytes_to_digits(response[6], response[7])) * 0.01
result.bms_current_amps = BMSScalarField(
help="Current", raw_value=current, value="{:.2f}".format(current), units="A"
)
if debug > 1:
debugger(" Current: {:.2f}A".format(current))
res_cap = bytes_to_digits(response[8], response[9]) * 0.01
nom_cap = bytes_to_digits(response[10], response[11]) * 0.01
result.bms_capacity_remaining_ah = BMSScalarField(
help="Remaining Capacity",
raw_value=res_cap,
value="{:.2f}".format(res_cap),
units="Ah",
)
result.bms_capacity_nominal_ah = BMSScalarField(
help="Nominal Capacity",
raw_value=nom_cap,
value="{:.2f}".format(nom_cap),
units="Ah",
)
if debug > 1:
debugger(" Remaining capacity: {:.2f}Ah".format(res_cap))
debugger(" Nominal capacity: {:.2f}Ah".format(nom_cap))
cycle_times = bytes_to_digits(response[12], response[13])
result.bms_charge_cycles = BMSScalarField(
help="Charge Cycles", raw_value=cycle_times, value="{0}".format(cycle_times)
)
if debug > 1:
debugger(" Cycle times: {0}".format(cycle_times))
man_date = bytes_to_date(response[14], response[15])
result.bms_manufacture_date = BMSInfoField(
help="Date of Manufacture", info="{0}".format(man_date)
)
if debug > 1:
debugger(" Manufacturing date: {0}".format(man_date))
cells = response[25]
result.bms_cell_number = BMSScalarField(
help="Cells", raw_value=cells, value="{0}".format(cells)
)
if debug > 1:
debugger(" Cells: {0}S".format(cells))
balance_state_high = bytes_to_digits(response[16], response[17]) # 1S to 16S
balance_state_low = bytes_to_digits(response[18], response[19]) # 17S to 32S
# 1 bit per 4S (2 bytes = 16S); in 4S, we should expect:
# 0x0 (no cells balancing) 0
# 0x1 (cell 1 balancing) 1
# 0x2 (cell 2 balancing) 2
# 0x3 (cells 1 + 2 balancing) 3
# 0x4 (cell 3 balancing) 4
# 0x5 (cells 1 + 3 balancing) 5
# 0x6 (cells 2 + 3 balancing) 6
# 0x7 (cells 1 + 2 + 3 balancing) 7
# 0x8 (cell 4 balancing) 8
# 0x9 (cells 1 + 4 balancing) 9
# 0xA (cells 2 + 4 balancing) 10
# 0xB (cells 1 + 2 + 4 balancing) 11
# 0xC (cells 3 + 4 balancing) 12
# 0xD (cells 1 + 3 + 4 balancing) 13
# 0xE (cells 2 + 3 + 4 balancing) 14
# 0xF (cells 1 + 2 + 3 + 4 balancing) 15
raw_balancing = {}
str_balancing = {}
for cell in range(cells):
# Cells 1-16 are recorded in balance_state_low, 17-32 in balance_state_high;
# hilo_cell records the offset relative to the state group
if cell >= 16:
state = balance_state_high
hilo_cell = cell - 16
else:
state = balance_state_low
hilo_cell = cell
# Cells are recorded as groups of 4 bits (0x0-0xF) per 4 cells
g = int(hilo_cell / 4)
b = 2 ** (hilo_cell - (g * 4))
balancing = bool((state >> g) & b)
raw_balancing[cell + 1] = balancing
str_balancing[cell + 1] = "{0}".format(int(balancing))
if debug > 1:
debugger(" Balancing cell {0}: {1}".format(cell, balancing))
result.bms_cells_balancing = BMSMultiField(
help="Cells balancing",
label="cell",
raw_values=raw_balancing,
values=str_balancing,
)
protection_state = bytes_to_digits(response[20], response[21])
sop = protection_state & 1
sup = protection_state & 2
gop = protection_state & 4
gup = protection_state & 8
cotp = protection_state & 16
cutp = protection_state & 32
dotp = protection_state & 64
dutp = protection_state & 128
cocp = protection_state & 256
docp = protection_state & 512
scp = protection_state & 1024
fdic = protection_state & 2048
slm = protection_state & 4096
def _prot(help, flag):
return BMSScalarField(
help=help, raw_value=bool(flag), value="{0}".format(int(bool(flag)))
)
result.bms_protection_sop_bool = _prot("Single overvoltage protection", sop)
result.bms_protection_sup_bool = _prot("Single undervoltage protection", sup)
result.bms_protection_wgop_bool = _prot("Whole group overvoltage protection", gop)
result.bms_protection_wgup_bool = _prot("Whole group undervoltage protection", gup)
result.bms_protection_cotp_bool = _prot(
"Charging over-temperature protection", cotp
)
result.bms_protection_cutp_bool = _prot(
"Charging under-temperature protection", cutp
)
result.bms_protection_dotp_bool = _prot(
"Discharging over-temperature protection", dotp
)
result.bms_protection_dutp_bool = _prot("Discharging under-protection", dutp)
result.bms_protection_cocp_bool = _prot("Charging over-current protection", cocp)
result.bms_protection_docp_bool = _prot("Discharging over-current protection", docp)
result.bms_protection_scp_bool = _prot("Short-circuit protection", scp)
result.bms_protection_fdic_bool = _prot("Front detection IC error", fdic)
result.bms_protection_slmos_bool = _prot("Software lock MOS", slm)
if debug > 2:
debugger(" Protection state: {0}".format(protection_state))
for attr in (
"sop",
"sup",
"gop",
"gup",
"cotp",
"cutp",
"dotp",
"dutp",
"cocp",
"docp",
"scp",
"fdic",
"slm",
):
val = locals()[attr]
debugger(" {}: {}".format(attr, bool(val)))
rsoc = response[23] * 0.01
result.bms_capacity_charge_ratio = BMSScalarField(
help="Percent Charge", raw_value=rsoc, value="{0}".format(rsoc), units="‰"
)
if debug > 1:
debugger(" Capacity remaining: {0}%".format(rsoc * 100))
# bit0 = charging; bit1 = discharging; 0 = MOS closing; 1 = MOS opening
control_status = response[24]
result.bms_charge_is_charging = BMSScalarField(
help="MOSFET charging",
raw_value=bool(control_status & 1),
value="{0}".format(int(bool(control_status & 1))),
)
result.bms_charge_is_discharging = BMSScalarField(
help="MOSFET discharging",
raw_value=bool((control_status >> 1) & 1),
value="{0}".format(int(bool((control_status >> 1) & 1))),
)
if debug > 1:
debugger(
" MOSFET charging: {0}".format("yes" if (control_status & 1) else "no")
)
debugger(
" MOSFET discharging: {0}".format(
"yes" if ((control_status >> 1) & 1) else "no"
)
)
ntc_num = response[26]
temperatures = [
(bytes_to_digits(response[27 + (2 * i)], response[28 + (2 * i)]) - 2731) * 0.1
for i in range(ntc_num)
]
result.bms_temperature_sensor_num = BMSScalarField(
help="Temperature Sensors", raw_value=ntc_num, value="{0}".format(ntc_num)
)
result.bms_temperature_celsius = BMSMultiField(
help="Temperature",
label="sensor",
raw_values={i + 1: temp for i, temp in enumerate(temperatures)},
values={i + 1: "{:.2f}".format(temp) for i, temp in enumerate(temperatures)},
units="°C",
)
if debug > 1:
debugger(" Number of temperature sensors: {0}".format(ntc_num))
for i, temp in enumerate(temperatures):
debugger(" Temperature sensor {:d}: {:.2f}°C".format(i + 1, temp))
return result
def parse_04_response(response: bytearray, debug: int = 0) -> BMSMultiField | bool:
"""Parse a JBD BMS 0x04 (cell voltages) response packet into a BMSMultiField."""
# Response is 7 + cells * 2 bytes:
# 00 begin: \xDD
# 01 r/w: \xA5
# 02 status: \x00 = correct; \x80 = incorrect
# 03 length (usually 8)
# 04 data (size of length)
# ...
# length+4 checksum
# length+5 checksum
# length+6 end \x77
if bytes([response[0]]) != b"\xdd":
debugger(
"parse_04_response ERROR: first byte not found: {0}".format(response[0])
)
return False
if bytes([response[2]]) == b"\x80":
debugger(
"parse_04_response ERROR: error byte returned from BMS: {0}".format(
response[2]
)
)
return False
data_len = response[3]
if debug > 2:
debugger(" Data length (trimming 4 bytes): {0}".format(data_len))
# The checksum is two bytes, offset by data_len + 4
# Five bytes at the front of data: begin; rw; status, command; length
# The checksum should check command, length, and data: [3] to [3+data_len+1]
first = data_len + 4
second = data_len + 5
if second > len(response):
debugger("parse_04_response ERROR: cell voltage checksum not found")
return False
checksum = bytes([response[first], response[second]])
if not verify_checksum(response[3:first], checksum):
debugger("parse_04_response ERROR: failed to validate received checksum")
return False
if data_len == 0: # pragma: no cover
return False
raw_values = {}
str_values = {}
for cell in range(int(data_len / 2)):
lo = (cell * 2) + 4
hi = (cell * 2) + 5
cellv = bytes_to_digits(response[lo], response[hi]) * 0.001
raw_values[cell + 1] = cellv
str_values[cell + 1] = "{:.3f}".format(cellv)
if debug > 1:
debugger(" Cell {:.0f}: {:.3f}V".format(cell + 1, cellv))
return BMSMultiField(
help="Cell Voltages",
label="cell",
raw_values=raw_values,
values=str_values,
units="V",
)