Repository navigation
Read a JBD battery management system with a JbdBms module - #372
JensOgorek wants to merge 7 commits into
Conversation
`bms = JbdBms(serial)` asks a BMS with the JBD protocol for its basic information every `interval` seconds and keeps voltage, current, capacities, state of charge, cycles, mean temperature, protection flags, MOSFETs and cell count in properties, so a port expander can send them in a telemetry frame instead of the host asking through an unmuted serial and parsing the reply. Answers are collected across steps, since the BMS may pause within one; missing, late and invalid answers count in `errors`. `cell_voltages()` and `hardware_version()` print the other two registers.
cell_voltages() and hardware_version() sent their request right away, so a call during an awaited basic-info answer lost both answers and counted two errors; they now wait until no answer is awaited. Bytes that arrive while nothing is awaited are dropped, so a late answer counts only at its timeout. last_message_age now tells the age of the values and restarts only with the basic information. The properties are read with properties.at() like in the other modules instead of through 13 cached pointers, a word() helper decodes the big-endian numbers, the header loses its class comment and gets the usual _ptr alias, and reply_millis becomes last_message_millis. The docs list the protection bits, drop the internals and show the telemetry example on a serial bus peer, where it works on main.
…swer The constructor started last_message_age at the creation of the module, so before the BMS answered, a reader saw 0 V with a small age (found on F30 behind an ExpanderBus). Like core.last_message_age, it now starts at 0, so until the first valid basic information it counts from the boot, and millis minus last_message_age is about 0, which tells a reader that no answer has arrived yet. On the bench with the PC as a silent BMS and the module created 8 s after the boot, the age right after creating the module was 196 ms and is now 8106 ms, i.e. core.millis; once the BMS answers, both builds show the age of the last answer.
|
Before the BMS answers, JbdBms reports 0 V, and with zauberzeug/lizard#372 its last_message_age counts from the boot, so the expander's millis minus the age is about 0 until the first answer. BmsHardware now drops a reading with that date instead of one with 0 V, which also holds after the expander restarted. The dates are compared across the wrap of the expander's millis, which as an int field of its frame turn negative after 24.9 days (zauberzeug/lizard#373); before, the same answer would have counted as a new one at that point.
…the awaited one handle_frame() treated a frame for another register like a corrupt one: it counted an error and gave up the awaited answer, so the answer that then came for the real request was dropped unseen. That happened when the BMS answered later than the 500 ms timeout while a method request was queued: the timeout sent the method's request in the same step, before the late basic-info answer had arrived, so that answer counted a second time and the method printed nothing; the mirror image with a late method answer and the next poll alike. A frame with a valid end and checksum for another register is now skipped without an error, since its timeout counted it already, and the module keeps waiting for the awaited answer; only its status and length count as errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
The module table of AGENTS.md did not know the new module, nor ImuBno085. MksServoMotor is still missing, since adding it to the longest row would make mdformat re-pad the whole table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
The poll measured its interval from request_millis, which every request sets, also those of cell_voltages() and hardware_version(). A method call thus pushed the next poll out by a full interval, and calls more frequent than the interval starved it: with 10 calls 0.53 s apart at an interval of 1 s, no basic information arrived for about 5 s. request_millis now serves only the timeout of the awaited answer, and a new poll_millis, set with each request for the basic information, serves the interval. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
… of 273.15 The JBD protocol gives a temperature in 0.1 K with 2731 for 0 °C, so the BMS and its manufacturer's app use 273.1 K, and the module, which subtracted 273.15, showed every temperature 0.05 K too low: on F30 24.85 °C for the raw 2980, which is 24.9 °C by the protocol. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
falkoschindler
left a comment
There was a problem hiding this comment.
@JensOgorek The module reads well: protocol, checksum, the field layout of the basic information and the buffer bounds all check out, and the bench plus F30 cover the normal cases. I pushed four small commits to the branch (details below); two things remain for you.
A failed method call stays silent. When the answer to cell_voltages() or hardware_version() is missing or comes with an error status (0x80, which older JBD firmwares return for register 0x05), count_error() only increments errors. Whoever types the method at the console sees no reaction and has to look at bms.errors. For the poll the counter is the right form, but for a method whose only purpose is printing, silence is unusual in Lizard. Would you echo a short warning for the two method registers, e.g. warning: bms got no cell voltages?
The 0.05 °C in the PR description. The sentence that attributes the 0.05 °C to the PC's rounding is not quite right: the module subtracted 273.15 where the protocol defines (raw - 2731) / 10, and the bench PC encodes with 273.15 and rounds up as well (25 °C as 2982, otherwise the mean would not come out as 26.05). With the formula fixed on the branch (c0dcf6f), the bench now reads 25.1 and 27.1 °C from those frames. Could you drop the sentence and, if you rerun the bench, let the PC encode with 2731 so the temperatures come out exact?
Fixed on the branch
- A valid answer for another register gave up the awaited one (c09a2b0).
handle_frame()treated a frame for another register like a corrupt one: it counted an error and clearedrequested_register, so the answer that then came for the real request was dropped unseen. That happened when the BMS answered later than the 500 ms timeout while a method request was queued: the timeout sent the method's request in the same step, before the late basic-info answer had arrived, so that answer counted a second time and the method printed nothing; the mirror image with a late method answer and the next poll alike. A frame with a valid end and checksum for another register is now skipped without an error, since its timeout counted it already, and only the status and length of the matching answer count as errors. - Method calls pushed the next poll out (8d6305f). The poll measured its interval from
request_millis, which every request sets, also those of the methods, so a call delayed the next basic-info poll by a full interval, and calls more frequent than the interval starved it: with your bench cadence of 10 calls 0.53 s apart at an interval of 1 s, no basic information arrived for about 5 s. A newpoll_millis, set with each basic-info request, now drives the interval;request_millisserves only the timeout. - Temperature 0.05 K too low (c0dcf6f). The protocol gives the temperature in 0.1 K with 2731 for 0 °C, so the manufacturer's app uses 273.1 K; the module subtracted 273.15 and showed e.g. 24.85 °C on F30 for the raw 2980, which is 24.9 °C by the protocol. Now
(raw - 2731) / 10.0. JbdBmsin the module table of AGENTS.md (843b477), together withImuBno085, which was missing too.
Motivation
RoSys reads the BMS of a Feldfreund by writing the request bytes
0xDD 0xA5 0x03 0x00 0xFF 0xFD 0x77to an unmutedSerialon the port expander every 5 s, every second while docking, and parsing the bytes the serial prints. Every reading is a round trip from the host, the readout broke when an unmuted serial started to split the 34-byte answer into two lines (#326), and with the port expander as a serial bus peer (#348) the answer would travel as a relayed text line. With this module the expander asks the BMS itself and can send the values in a telemetry frame.Implementation
bms = JbdBms(serial)asks for the basic information (register 0x03) everyintervalseconds, 1 s by default and none with 0, and keeps voltage, current, residual and nominal capacity, state of charge, cycles, mean temperature, protection flags, MOSFETs and the number of cells in properties.errors, and the properties keep their last values;last_message_agetells how old they are, and until the first answer it counts from the boot, likecore.last_message_age.bms.cell_voltages()andbms.hardware_version()print registers 0x04 and 0x05; their requests wait until no answer is awaited, so a call never cuts into the periodic request.Progress
Bench: the module against a BMS played by a PC
One ESP32 runs
bms = JbdBms(Serial(27, 26, 9600, 1)); the PC answers on the same wires through the USB serial bridge of a second ESP32 board held in reset, like a JBD BMS with frames from 0xDD to 0x77 and their checksum. The PC answers with 25.6 V, −1.23 A, 50 %, 8 cells, 42 cycles, 50 of 100 Ah and two sensors at 25 and 27 °C, and changes the values for each case, so that a value taken over shows. ESP-IDF 5.3.1, this branch282f0b9.errors(total)last_message_age11.5 sprotections16The 0.05 °C come from the PC rounding the temperatures to 0.1 K, the protocol's resolution.
bms.cell_voltages()printedbms cell voltages: 3.301 3.299 3.305 3.300 3.298 3.302 3.301 3.300,bms.hardware_version()printedbms hardware version: JBD-SP04S034-L8S.interval1 s: 10 requests in 10 s; 0.1 s: 50 in 5 s; 0: none in 3 s.A method call while a basic-information answer is awaited: the PC answers register 0x03 only after 200 ms, and the console calls the method right after the PC has seen a 0x03 request. The first version of this PR sent the method's request at once, so both answers failed the register check.
3f50a6d(first version)282f0b9bms.cell_voltages()10×,interval1 serrors+20errors+0bms.cell_voltages(); bms.hardware_version()5×bms.cell_voltages()10×,interval0.1 sBench:
last_message_agebefore the first answer, before and after 57fe134Same bench, the PC silent at first; the module is created at the console about 8 s after the boot, and later the PC starts answering.
282f0b957fe134core.millis8116, age 196 ms, 0 Vcore.millisminus the age dates the last answer: before, that was the module's creation (7920 ms) until the first answer; now it is 0.F30: the BMS of a Feldfreund through its port expander
P0 of F30 ran
bms = JbdBms(Serial(26, 27, 9600, 2))andcore.telemetry(bms.voltage, bms.current, bms.percentage, bms.temperature, bms.protections, bms.errors, bms.last_message_age, 1000), and the core mirrored the frame through an expander bus (#371), on a test build with both branches, first with3f50a6d, then with282f0b9. RoSys was stopped.bms.cell_voltages(): 3.657 3.656 3.657 3.657 3.656 3.654 3.652 3.650, which add up to the 29.24 V;bms.hardware_version():OH17SA01-L17S-100A.errorswas 0 at every reading during the test of a few minutes.282f0b9atinterval0.1 s, 10 calls ofbms.cell_voltages()0.53 s apart and 5 of both methods 0.77 s apart, and at 1 s, 10 calls 1.37 s apart: all 25 cell-voltage and 5 hardware-version lines arrived,errorsstayed 0, and the core's mirrors matched P0.※