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.
377 lines
13 KiB
Python
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",
|
|
)
|